From 5138bd7b564d32ae3b645c1fbe2ca0b58f60e2e4 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 18:46:40 +0200 Subject: [PATCH 01/36] fix observations on MultiBoxSpace --- src/armscan_env/envs/observations.py | 71 ++++++++++++++-------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/armscan_env/envs/observations.py b/src/armscan_env/envs/observations.py index f685364..ffaac4c 100644 --- a/src/armscan_env/envs/observations.py +++ b/src/armscan_env/envs/observations.py @@ -5,7 +5,6 @@ Generic, TypedDict, TypeVar, - Union, cast, ) @@ -32,7 +31,25 @@ class ChanneledLabelmapsObsWithActReward(TypedDict): reward: np.ndarray -TDict = TypeVar("TDict", bound=Union[dict, ChanneledLabelmapsObsWithActReward]) # noqa +class ActionRewardDict(TypedDict): + action: np.ndarray + reward: np.ndarray + + +class ClusteringCharsDict(TypedDict): + num_clusters: np.ndarray + num_points: np.ndarray + cluster_center_mean: np.ndarray + + +class ClusterObservationDict(ClusteringCharsDict, ActionRewardDict): + pass + + +TDict = TypeVar( + "TDict", + bound=dict | ChanneledLabelmapsObsWithActReward | ClusteringCharsDict | ActionRewardDict, +) class MultiBoxSpace(gym.spaces.Dict, Generic[TDict]): @@ -238,21 +255,6 @@ def observation_space(self) -> MultiBoxSpace: return self._observation_space -class ActionRewardDict(TypedDict): - action: np.ndarray - reward: np.ndarray - - -class ClusteringCharsDict(TypedDict): - num_clusters: np.ndarray - num_points: np.ndarray - cluster_center_mean: np.ndarray - - -class ClusterObservationDict(ClusteringCharsDict, ActionRewardDict): - pass - - class ActionRewardObservation(DictObservation[LabelmapStateAction]): """Observation containing (normalized) action and a computed `reward`. @@ -269,19 +271,17 @@ def action_shape(self) -> tuple[int]: return self._action_shape @cached_property - def observation_space(self) -> gym.spaces.Dict: - return gym.spaces.Dict( - spaces=( - ("action", gym.spaces.Box(low=-1, high=1, shape=self.action_shape)), - ("reward", gym.spaces.Box(low=-1, high=0, shape=(1,))), - ), + def observation_space(self) -> MultiBoxSpace: + return MultiBoxSpace[ActionRewardDict]( + name2box={ + "reward": gym.spaces.Box(low=-1, high=0, shape=(1,)), + "action": gym.spaces.Box(low=-1, high=1, shape=self.action_shape), + }, ) def compute_observation(self, state: LabelmapStateAction) -> ActionRewardDict: tissue_clusters = TissueClusters.from_labelmap_slice(state.labels_2d_slice) - clustering_reward = anatomy_based_rwd(tissue_clusters=tissue_clusters) - return { "action": state.normalized_action_arr, "reward": np.array([clustering_reward], dtype=np.float32), @@ -292,16 +292,17 @@ class LabelmapClusterObservation(DictObservation[LabelmapStateAction]): """Observation for a flat array representation of a clustered labelmap slice.""" @cached_property - def observation_space(self) -> gym.spaces.Dict: - return gym.spaces.Dict( - spaces=( - ("num_clusters", gym.spaces.Box(low=0, high=np.inf, shape=(len(TissueLabel),))), - ("num_points", gym.spaces.Box(low=0, high=np.inf, shape=(len(TissueLabel),))), - ( - "cluster_center_mean", - gym.spaces.Box(low=-np.inf, high=np.inf, shape=(2 * len(TissueLabel),)), + def observation_space(self) -> MultiBoxSpace: + return MultiBoxSpace[ClusteringCharsDict]( + name2box={ + "num_clusters": gym.spaces.Box(low=0, high=np.inf, shape=(len(TissueLabel),)), + "num_points": gym.spaces.Box(low=0, high=np.inf, shape=(len(TissueLabel),)), + "cluster_center_mean": gym.spaces.Box( + low=-np.inf, + high=np.inf, + shape=(2 * len(TissueLabel),), ), - ), + }, ) def compute_observation(self, state: LabelmapStateAction) -> ClusteringCharsDict: @@ -329,8 +330,8 @@ def compute_observation(self, state: LabelmapStateAction) -> ClusteringCharsDict else: clusters_center_mean = np.zeros(2, dtype=float) - tissues_num_points[i] = num_points tissues_num_clusters[i] = num_clusters + tissues_num_points[i] = num_points tissues_cluster_centers[i] = clusters_center_mean # keep in sync with observation_space From e0eee647482d9908fefa6b2d533f8fe5e620957a Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 18:51:10 +0200 Subject: [PATCH 02/36] fix volume transformation, create projection for negative transformations change slicing input to ManipulatorAction --- docs/02_notebooks/L3_slicing.ipynb | 15 +- docs/02_notebooks/L4_environment.ipynb | 2 +- docs/02_notebooks/L5_linear_sweep.ipynb | 4 +- scripts/random_volume_transformations.ipynb | 209 +++++++++++++++++++ src/armscan_env/envs/labelmaps_navigation.py | 2 +- src/armscan_env/envs/state_action.py | 24 ++- src/armscan_env/slicing.py | 172 --------------- src/armscan_env/volumes/loading.py | 17 ++ src/armscan_env/volumes/slicing.py | 190 +++++++++++++++++ test/armscan_env/test_labelmap_volumes.py | 2 +- 10 files changed, 450 insertions(+), 187 deletions(-) create mode 100644 scripts/random_volume_transformations.ipynb delete mode 100644 src/armscan_env/slicing.py create mode 100644 src/armscan_env/volumes/loading.py create mode 100644 src/armscan_env/volumes/slicing.py diff --git a/docs/02_notebooks/L3_slicing.ipynb b/docs/02_notebooks/L3_slicing.ipynb index 31f213e..ee1b4da 100644 --- a/docs/02_notebooks/L3_slicing.ipynb +++ b/docs/02_notebooks/L3_slicing.ipynb @@ -252,13 +252,11 @@ "metadata": {}, "outputs": [], "source": [ - "from armscan_env.slicing import slice_volume\n", + "from armscan_env.envs.state_action import ManipulatorAction\n", + "from armscan_env.volumes.slicing import slice_volume\n", "\n", "sliced_volume = slice_volume(\n", - " z_rotation=19.3,\n", - " x_rotation=0.0,\n", - " x_trans=0.0,\n", - " y_trans=140.0,\n", + " action=ManipulatorAction(rotation=(19.3, 0), translation=(0, 140)),\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", @@ -323,9 +321,10 @@ " sliced_volume = slice_volume(\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", - " z_rotation=z[i],\n", - " x_rotation=0,\n", - " y_trans=t[i],\n", + " action=ManipulatorAction(\n", + " rotation=(z[i], 0),\n", + " translation=(0, t[i]),\n", + " ),\n", " )\n", " sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", " ax2.set_title(f\"Slice {i}\")\n", diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index 8bf0592..ea4f95d 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -37,8 +37,8 @@ "from armscan_env.clustering import TissueClusters\n", "from armscan_env.config import get_config\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", - "from armscan_env.slicing import slice_volume\n", "from armscan_env.util.visualizations import show_clusters\n", + "from armscan_env.volumes.slicing import slice_volume\n", "from IPython.core.display import HTML\n", "\n", "config = get_config()" diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index 9456e2f..d1c44d5 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -193,7 +193,7 @@ "volume_size = volume_1.GetSize()\n", "\n", "projected_env = ArmscanEnvFactory(\n", - " name2volume={\"1\": volume_1},\n", + " name2volume={\"2\": volume_2},\n", " observation=ActionRewardObservation(action_shape=(1,)).to_array_observation(),\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " reward_metric=LabelmapClusteringBasedReward(),\n", @@ -250,7 +250,7 @@ { "cell_type": "code", "execution_count": null, - "id": "4fb82c1487521b11", + "id": "ce4695958dbc75a", "metadata": {}, "outputs": [], "source": [] diff --git a/scripts/random_volume_transformations.ipynb b/scripts/random_volume_transformations.ipynb new file mode 100644 index 0000000..1931c7d --- /dev/null +++ b/scripts/random_volume_transformations.ipynb @@ -0,0 +1,209 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "code", + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ], + "id": "60c69d9345beb9d0", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import SimpleITK as sitk\n", + "from armscan_env import config\n", + "from armscan_env.clustering import TissueClusters\n", + "from armscan_env.envs.state_action import ManipulatorAction\n", + "from armscan_env.util.visualizations import show_clusters\n", + "from armscan_env.volumes.slicing import EulerTransform, slice_volume, transform_volume\n", + "\n", + "config = config.get_config()" + ], + "id": "bf5c60e86d1e8e19", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volume = sitk.ReadImage(config.get_labels_path(1))\n", + "volume_img = sitk.GetArrayFromImage(volume)\n", + "plt.imshow(volume_img[40, :, :])\n", + "action = ManipulatorAction(rotation=(19, 0), translation=(0, 140))\n", + "\n", + "o = volume.GetOrigin()\n", + "x_dash = np.arange(volume_img.shape[2])\n", + "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + action.translation[1], o[2]])[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "\n", + "plt.show()" + ], + "id": "ae347cf3897968a6", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volume.GetDirection()" + ], + "id": "dab795a261809b07", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(5, 0))\n", + "transformed_volume = transform_volume(volume, volume_transformation)\n", + "\n", + "volume_transformation_matrix = EulerTransform(volume_transformation).get_transform()\n", + "print(f\"Volume transformation: \\n{volume_transformation_matrix}\")\n", + "# the action must be transformed by the inverse of the volume transformation\n", + "action_transformation_matrix = np.linalg.inv(volume_transformation_matrix)\n", + "print(f\"Action transformation: \\n{action_transformation_matrix}\")\n", + "transformed_action = EulerTransform(volume_transformation).transform_action(action)\n", + "print(f\"Transformed action: {transformed_action}\")\n", + "\n", + "transformed_img = sitk.GetArrayFromImage(transformed_volume)\n", + "plt.imshow(transformed_img[40, :, :])\n", + "\n", + "ot = transformed_volume.GetOrigin()\n", + "x_dash = np.arange(transformed_img.shape[2])\n", + "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + transformed_action.translation[1], o[2]])[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(transformed_action.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "\n", + "plt.show()" + ], + "id": "309c559805bcb3fe", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volume.GetDirection()" + ], + "id": "c4ec806cd695481f", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "sliced_volume = slice_volume(\n", + " action=action,\n", + " volume=volume,\n", + " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", + ")\n", + "sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", + "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", + "\n", + "slice = sliced_img\n", + "plt.imshow(slice, aspect=6)\n", + "plt.show()" + ], + "id": "cb9c333a74781d5a", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "sliced_transformed_volume = slice_volume(\n", + " action=transformed_action,\n", + " volume=transformed_volume,\n", + " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", + ")\n", + "sliced_img = sitk.GetArrayFromImage(sliced_transformed_volume)[:, 0, :]\n", + "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", + "\n", + "slice = sliced_img\n", + "plt.imshow(slice, aspect=6)\n", + "plt.show()" + ], + "id": "acda09e94c3f2f2b", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", + "volume_2_img = sitk.GetArrayFromImage(volume_2)\n", + "spacing = volume_2.GetSpacing()\n", + "plt.imshow(volume_2_img[51, :, :])\n", + "action = ManipulatorAction(rotation=(5, 0), translation=(0, 112))\n", + "\n", + "o = volume_2.GetOrigin()\n", + "x_dash = np.arange(volume_2_img.shape[2])\n", + "b = volume_2.TransformPhysicalPointToIndex([o[0], o[1] + action.translation[1], o[2]])[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "\n", + "plt.show()" + ], + "id": "fb6cfecff1cb7cd4", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "sliced_volume_2 = slice_volume(\n", + " action=action,\n", + " volume=volume_2,\n", + " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", + ")\n", + "sliced_img_2 = sitk.GetArrayFromImage(sliced_volume_2)[:, 0, :]\n", + "np.save(\"./array\", sliced_img_2)\n", + "\n", + "cluster = TissueClusters.from_labelmap_slice(sliced_img_2.T)\n", + "show_clusters(cluster, sliced_img_2.T, aspect=spacing[2] / spacing[0])\n", + "\n", + "plt.show()" + ], + "id": "6462b823c7903838", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 6b22129..81f72fe 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -15,8 +15,8 @@ ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.envs.state_action import LabelmapStateAction, ManipulatorAction -from armscan_env.slicing import slice_volume, transform_volume from armscan_env.util.visualizations import show_clusters +from armscan_env.volumes.slicing import slice_volume, transform_volume from celluloid import Camera from IPython.core.display import HTML from matplotlib import pyplot as plt diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index 3044d42..defcf90 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -1,4 +1,5 @@ import logging +import warnings from dataclasses import dataclass from typing import Self @@ -29,6 +30,9 @@ def to_normalized_array( # normalize translation to [-1, 1]: 0 -> -1, translation_bounds -> 1 rotation = np.zeros(2) translation = np.zeros(2) + if self.translation[0] < 0 or self.translation[1] < 0: + log.info("Projecting to positive because negative defined translation") + self.project_to_positive() for i in range(2): if rotation_bounds[i] == 0.0: rotation[i] = 0.0 @@ -43,7 +47,7 @@ def to_normalized_array( result = np.concatenate([rotation, translation]) if not (result >= -1).all() or not (result <= 1).all(): - raise ValueError( + warnings.warn( f"Angles or translations are out of bounds: " f"{self.rotation=}, {self.translation=}," f" {rotation_bounds=}, {translation_bounds=}", @@ -63,7 +67,7 @@ def from_normalized_array( if not (action.shape == (4,)): raise ValueError(f"Action has wrong shape: {action.shape=}\nShould be (4,)") if not (action >= -1).all() or not (action <= 1).all(): - raise ValueError( + warnings.warn( f"Action is not normalized: {action=}\nShould be in the range [-1, 1]", ) if None in translation_bounds: @@ -76,6 +80,22 @@ def from_normalized_array( return cls(rotation=tuple(rotation), translation=tuple(translation)) # type: ignore + def project_to_positive(self) -> None: + """Project the action to the positive octant.""" + tx, ty = self.translation + thz, thx = self.rotation + log.info(f"Translation before projection: {self.translation}") + while tx < 0 or ty < 0: + if tx < 0: + ty = (np.tan(np.deg2rad(thz)) * (-tx)) + ty + tx = 0 + if ty < 0: + tx = ((1 / np.tan(np.deg2rad(thz))) * (-ty)) + tx + ty = 0 + translation = (tx, ty) + log.info(f"Translation after projection: {translation}") + self.translation = translation + @dataclass(kw_only=True) class LabelmapStateAction(StateAction): diff --git a/src/armscan_env/slicing.py b/src/armscan_env/slicing.py deleted file mode 100644 index d41c27e..0000000 --- a/src/armscan_env/slicing.py +++ /dev/null @@ -1,172 +0,0 @@ -import numpy as np -import SimpleITK as sitk - - -def padding(original_array: np.ndarray) -> np.ndarray: - """Pad an array to make it square. - - :param original_array: array to pad - :return: padded array. - """ - # Find the maximum dimension - max_dim = max(original_array.shape) - - # Calculate padding for each dimension (left and right) - padding_x_left = (max_dim - original_array.shape[0]) // 2 - padding_x_right = max_dim - original_array.shape[0] - padding_x_left - - padding_y_left = (max_dim - original_array.shape[1]) // 2 - padding_y_right = max_dim - original_array.shape[1] - padding_y_left - - padding_z_left = (max_dim - original_array.shape[2]) // 2 - padding_z_right = max_dim - original_array.shape[2] - padding_z_left - - # Pad the array with zeros - padded_array = np.pad( - original_array, - ( - (padding_x_left, padding_x_right), - (padding_y_left, padding_y_right), - (padding_z_left, padding_z_right), - ), - mode="constant", - ) - - # Verify the shapes - print("Original Array Shape:", original_array.shape) - print("Padded Array Shape:", padded_array.shape) - - return padded_array - - -def transform_volume( - volume: sitk.Image, - z_rotation: float | np.ndarray = 0.0, - x_rotation: float | np.ndarray = 0.0, - x_trans: float | np.ndarray = 0.0, - y_trans: float | np.ndarray = 0.0, -) -> sitk.Image: - """Trasnform a 3D volume with arbitrary rotation and translation. - - :param z_rotation: rotation around z-axis in degrees - :param x_rotation: rotation around x-axis in degrees - :param x_trans: translation along x-axis - :param y_trans: translation along y-axis - :param volume: 3D volume to be sliced - :return: the sliced volume. - """ - # Euler's transformation - # Rotation is defined by three rotations around z1, x2, z2 axis - th_z1 = np.deg2rad(z_rotation) - th_x2 = np.deg2rad(x_rotation) - - o = np.array(volume.GetOrigin()) - - # transformation simplified at z2=0 since this rotation is never performed - eul_tr = np.array( - [ - [ - np.cos(th_z1), - -np.sin(th_z1) * np.cos(th_x2), - np.sin(th_z1) * np.sin(th_x2), - o[0] + x_trans, - ], - [ - np.sin(th_z1), - np.cos(th_z1) * np.cos(th_x2), - -np.cos(th_z1) * np.sin(th_x2), - o[1] + y_trans, - ], - [0, np.sin(th_x2), np.cos(th_x2), o[2]], - [0, 0, 0, 1], - ], - ) - - # Define plane's coordinate system - e1 = eul_tr[0][:3] - e2 = eul_tr[1][:3] - e3 = eul_tr[2][:3] - img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane - - direction = np.stack([e1, e2, e3], axis=0).flatten() - - resampler = sitk.ResampleImageFilter() - spacing = volume.GetSpacing() - - resampler.SetOutputDirection(direction.tolist()) - resampler.SetOutputOrigin(img_o.tolist()) - resampler.SetOutputSpacing(spacing) - resampler.SetSize(volume.GetSize()) - resampler.SetInterpolator(sitk.sitkNearestNeighbor) - - # Resample the volume on the arbitrary plane - return resampler.Execute(volume) - - -def slice_volume( - volume: sitk.Image, - slice_shape: tuple[int, int], - z_rotation: float | np.ndarray = 0.0, - x_rotation: float | np.ndarray = 0.0, - x_trans: float | np.ndarray = 0.0, - y_trans: float | np.ndarray = 0.0, -) -> sitk.Image: - """Slice a 3D volume with arbitrary rotation and translation. - - :param z_rotation: rotation around z-axis in degrees - :param x_rotation: rotation around x-axis in degrees - :param x_trans: translation along x-axis - :param y_trans: translation along y-axis - :param volume: 3D volume to be sliced - :param slice_shape: shape of the output slice - :return: the sliced volume. - """ - # Euler's transformation - # Rotation is defined by three rotations around z1, x2, z2 axis - th_z1 = np.deg2rad(z_rotation) - th_x2 = np.deg2rad(x_rotation) - - o = np.array(volume.GetOrigin()) - - # transformation simplified at z2=0 since this rotation is never performed - eul_tr = np.array( - [ - [ - np.cos(th_z1), - -np.sin(th_z1) * np.cos(th_x2), - np.sin(th_z1) * np.sin(th_x2), - o[0] + x_trans, - ], - [ - np.sin(th_z1), - np.cos(th_z1) * np.cos(th_x2), - -np.cos(th_z1) * np.sin(th_x2), - o[1] + y_trans, - ], - [0, np.sin(th_x2), np.cos(th_x2), o[2]], - [0, 0, 0, 1], - ], - ) - - # Define plane's coordinate system - e1 = eul_tr[0][:3] - e2 = eul_tr[1][:3] - e3 = eul_tr[2][:3] - img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane - - direction = np.stack([e1, e2, e3], axis=0).flatten() - - resampler = sitk.ResampleImageFilter() - spacing = volume.GetSpacing() - - w = slice_shape[0] - h = slice_shape[1] - - resampler.SetOutputDirection(direction.tolist()) - resampler.SetOutputOrigin(img_o.tolist()) - resampler.SetOutputSpacing(spacing) - resampler.SetSize((w, 3, h)) - resampler.SetInterpolator(sitk.sitkNearestNeighbor) - - # Resample the volume on the arbitrary plane - return resampler.Execute(volume) diff --git a/src/armscan_env/volumes/loading.py b/src/armscan_env/volumes/loading.py new file mode 100644 index 0000000..fb26d54 --- /dev/null +++ b/src/armscan_env/volumes/loading.py @@ -0,0 +1,17 @@ +import SimpleITK as sitk + + +def load_sitk_volume( + path: str, + spacing: tuple[float, float, float] | None = (0.5, 0.5, 1), +) -> sitk.Image: + """Load a SimpleITK volume from a file. + + :param path: path to the volume file + :param spacing: spacing of the volume + :return: the loaded volume + """ + volume = sitk.ReadImage(path) + if spacing is not None: + volume.SetSpacing(spacing) + return volume diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py new file mode 100644 index 0000000..6c85d25 --- /dev/null +++ b/src/armscan_env/volumes/slicing.py @@ -0,0 +1,190 @@ +import logging + +import numpy as np +import SimpleITK as sitk +from armscan_env.envs.state_action import ManipulatorAction + +log = logging.getLogger(__name__) + + +def padding(original_array: np.ndarray) -> np.ndarray: + """Pad an array to make it square. + + :param original_array: array to pad + :return: padded array. + """ + # Find the maximum dimension + max_dim = max(original_array.shape) + + # Calculate padding for each dimension (left and right) + padding_x_left = (max_dim - original_array.shape[0]) // 2 + padding_x_right = max_dim - original_array.shape[0] - padding_x_left + + padding_y_left = (max_dim - original_array.shape[1]) // 2 + padding_y_right = max_dim - original_array.shape[1] - padding_y_left + + padding_z_left = (max_dim - original_array.shape[2]) // 2 + padding_z_right = max_dim - original_array.shape[2] - padding_z_left + + # Pad the array with zeros + padded_array = np.pad( + original_array, + ( + (padding_x_left, padding_x_right), + (padding_y_left, padding_y_right), + (padding_z_left, padding_z_right), + ), + mode="constant", + ) + + # Verify the shapes + print("Original Array Shape:", original_array.shape) + print("Padded Array Shape:", padded_array.shape) + + return padded_array + + +class EulerTransform: + def __init__(self, action: ManipulatorAction, origin: np.ndarray = np.zeros(3)): + self.action = action + self.origin = origin + + def get_transform(self) -> np.ndarray: + # Euler's transformation + # Rotation is defined by three rotations around z1, x2, z2 axis + th_z1 = np.deg2rad(self.action.rotation[0]) + th_x2 = np.deg2rad(self.action.rotation[1]) + + # transformation simplified at z2=0 since this rotation is never performed + return np.array( + [ + [ + np.cos(th_z1), + -np.sin(th_z1) * np.cos(th_x2), + np.sin(th_z1) * np.sin(th_x2), + self.origin[0] + self.action.translation[0], + ], + [ + np.sin(th_z1), + np.cos(th_z1) * np.cos(th_x2), + -np.cos(th_z1) * np.sin(th_x2), + self.origin[1] + self.action.translation[1], + ], + [0, np.sin(th_x2), np.cos(th_x2), self.origin[2]], + [0, 0, 0, 1], + ], + ) + + @staticmethod + def get_angles_from_rotation_matrix(rotation_matrix: np.ndarray) -> np.ndarray: + """Get the angles from a rotation matrix.""" + # Extract the angles from the rotation matrix + th_x2 = np.arcsin(rotation_matrix[2, 1]) + th_z1 = np.arcsin(rotation_matrix[1, 0]) + + # Convert the angles to degrees + th_z1 = np.rad2deg(th_z1) + th_x2 = np.rad2deg(th_x2) + + return np.array([th_z1, th_x2]) + + def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: + """Transform an action to be relative to the new coordinate system.""" + volume_transform_matrix = self.get_transform() + + action_matrix = EulerTransform(relative_action).get_transform() + # new_action_matrix = np.dot(np.linalg.inv(volume_transform_matrix), action_matrix) # 1_A_s = 1_T_0 * 0_A_s + + new_action_translation = action_matrix[:2, 3] - volume_transform_matrix[:2, 3] + + transformed_action = ManipulatorAction( + rotation=relative_action.rotation, + translation=new_action_translation, + ) + + log.info( + f"Random transformation: {self.action}\n" + f"Original action: {relative_action}\n" + f"Transformed action: {transformed_action}\n", + ) + + return transformed_action + + +def transform_volume( + volume: sitk.Image, + action: ManipulatorAction, +) -> sitk.Image: + """Trasnform a 3D volume with arbitrary rotation and translation. + + :param volume: 3D volume to be transformed + :param action: action to transform the volume + :return: the sliced volume. + """ + origin = np.array(volume.GetOrigin()) + euler_transform = EulerTransform(action, origin) + eul_tr = euler_transform.get_transform() + + # Define plane's coordinate system + e1 = eul_tr[0][:3] + e2 = eul_tr[1][:3] + e3 = eul_tr[2][:3] + img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane + + direction = np.stack([e1, e2, e3], axis=0).flatten() + + resampler = sitk.ResampleImageFilter() + spacing = volume.GetSpacing() + + resampler.SetOutputDirection(direction.tolist()) + resampler.SetOutputOrigin(img_o.tolist()) + resampler.SetOutputSpacing(spacing) + resampler.SetSize(volume.GetSize()) + resampler.SetInterpolator(sitk.sitkNearestNeighbor) + + # Todo: hack --> needed to break rotation dependency + volume.transformation_action = action # type: ignore + + # Resample the volume on the arbitrary plane + return resampler.Execute(volume) + + +def slice_volume( + volume: sitk.Image, + slice_shape: tuple[int, int], + action: ManipulatorAction, +) -> sitk.Image: + """Slice a 3D volume with arbitrary rotation and translation. + + :param volume: 3D volume to be sliced + :param slice_shape: shape of the output slice + :param action: action to transform the volume + :return: the sliced volume. + """ + o = np.array(volume.GetOrigin()) + euler_transform = EulerTransform(action, o) + eul_tr = euler_transform.get_transform() + + # Define plane's coordinate system + e1 = eul_tr[0][:3] + e2 = eul_tr[1][:3] + e3 = eul_tr[2][:3] + img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane + + # Todo: hack --> the action attribute set in transform_volume; find a better solution later + direction = np.stack([e1, e2, e3], axis=0).flatten() + + resampler = sitk.ResampleImageFilter() + spacing = volume.GetSpacing() + + w = slice_shape[0] + h = slice_shape[1] + + resampler.SetOutputDirection(direction.tolist()) + resampler.SetOutputOrigin(img_o.tolist()) + resampler.SetOutputSpacing(spacing) + resampler.SetSize((w, 3, h)) + resampler.SetInterpolator(sitk.sitkNearestNeighbor) + + # Resample the volume on the arbitrary plane + return resampler.Execute(volume) diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index f074364..5a99cb5 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -5,7 +5,7 @@ import SimpleITK as sitk from armscan_env.clustering import TissueLabel from armscan_env.config import get_config -from armscan_env.slicing import slice_volume +from armscan_env.volumes.slicing import slice_volume config = get_config() From 2e55354f4fb9cc18d4770196c45118c9ff571a42 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 18:51:50 +0200 Subject: [PATCH 03/36] fix clustering --- src/armscan_env/clustering.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/armscan_env/clustering.py b/src/armscan_env/clustering.py index bec801d..3cc543a 100644 --- a/src/armscan_env/clustering.py +++ b/src/armscan_env/clustering.py @@ -23,9 +23,9 @@ def find_DBSCAN_clusters(self, labelmap_slice: np.ndarray) -> list["DataCluster" case TissueLabel.BONES: return find_DBSCAN_clusters(self, labelmap_slice, eps=4.1, min_samples=46) case TissueLabel.TENDONS: - return find_DBSCAN_clusters(self, labelmap_slice, eps=4.1, min_samples=46) + return find_DBSCAN_clusters(self, labelmap_slice, eps=2.5, min_samples=15) case TissueLabel.ULNAR: - return find_DBSCAN_clusters(self, labelmap_slice, eps=2.5, min_samples=18) + return find_DBSCAN_clusters(self, labelmap_slice, eps=2.0, min_samples=10) case _: raise ValueError(f"Unknown tissue label: {self}") @@ -142,9 +142,10 @@ def find_DBSCAN_clusters( label_positions = np.array(list(zip(*np.where(binary_mask), strict=True))) clusterer = DBSCAN(eps=eps, min_samples=min_samples) clusters = clusterer.fit_predict(label_positions) - n_clusters = ( - len(np.unique(clusters)) - 1 - ) # noise cluster has label -1, we don't take it into account + if -1 in clusters: + n_clusters = len(np.unique(clusters)) - 1 + else: + n_clusters = len(np.unique(clusters)) log.debug(f"Found {n_clusters} clusters") cluster_list = [] From 5622434a31120b21e1a53129f7b52d8950c3aaa2 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 18:52:25 +0200 Subject: [PATCH 04/36] new volumes src --- src/armscan_env/volumes/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/armscan_env/volumes/__init__.py diff --git a/src/armscan_env/volumes/__init__.py b/src/armscan_env/volumes/__init__.py new file mode 100644 index 0000000..e69de29 From 893e4c97433cad66e06739a24ce68af3f68cf50f Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 18:55:12 +0200 Subject: [PATCH 05/36] AddRewardDetailsWrapper --- src/armscan_env/envs/base.py | 16 ++- src/armscan_env/wrapper.py | 227 +++++++++++++++++++++++++++-------- 2 files changed, 183 insertions(+), 60 deletions(-) diff --git a/src/armscan_env/envs/base.py b/src/armscan_env/envs/base.py index 9bfaf60..1d1b046 100644 --- a/src/armscan_env/envs/base.py +++ b/src/armscan_env/envs/base.py @@ -91,20 +91,18 @@ def __init__(self, array_observations: list[ArrayObservation[TStateAction]]): def compute_observation(self, state: TStateAction) -> np.ndarray: return np.concatenate( [obs.compute_observation(state) for obs in self.array_observations], - axis=0, + axis=1, ) @cached_property def observation_space(self) -> gym.spaces.Box: + return self.concatenate_boxes([obs.observation_space for obs in self.array_observations]) + + @staticmethod + def concatenate_boxes(boxes: list[gym.spaces.Box]) -> gym.spaces.Box: return gym.spaces.Box( - low=np.concatenate( - [obs.observation_space.low for obs in self.array_observations], - axis=0, - ), - high=np.concatenate( - [obs.observation_space.high for obs in self.array_observations], - axis=0, - ), + low=np.concatenate([box.low for box in boxes], axis=0), + high=np.concatenate([box.high for box in boxes], axis=0), ) diff --git a/src/armscan_env/wrapper.py b/src/armscan_env/wrapper.py index db1a116..50dc6fd 100644 --- a/src/armscan_env/wrapper.py +++ b/src/armscan_env/wrapper.py @@ -8,17 +8,24 @@ import numpy as np import SimpleITK as sitk -from armscan_env.envs.base import Observation, RewardMetric, TerminationCriterion +from armscan_env.clustering import TissueClusters +from armscan_env.envs.base import ( + ConcatenatedArrayObservation, + Observation, + RewardMetric, + TerminationCriterion, +) from armscan_env.envs.labelmaps_navigation import ( LabelmapEnv, LabelmapEnvTerminationCriterion, ) -from armscan_env.envs.observations import MultiBoxSpace -from armscan_env.envs.rewards import LabelmapClusteringBasedReward +from armscan_env.envs.observations import ActionRewardObservation, MultiBoxSpace +from armscan_env.envs.rewards import LabelmapClusteringBasedReward, anatomy_based_rwd from armscan_env.envs.state_action import LabelmapStateAction import gymnasium as gym from gymnasium.core import Env, Wrapper +from gymnasium.spaces import Box from gymnasium.spaces import Dict as DictSpace from gymnasium.vector.utils import batch_space, concatenate, create_empty_array from gymnasium.wrappers.utils import create_zero_array @@ -34,10 +41,39 @@ log = logging.getLogger(__name__) +# Todo: Issue on gymnasium for not overwriting reset method +class PatchedWrapper(Wrapper[np.ndarray, float, np.ndarray, np.ndarray]): + def __init__(self, env: LabelmapEnv | Env): + super().__init__(env) + # Helps with IDE autocompletion + self.env = cast(LabelmapEnv, env) + + def reset(self, **kwargs: Any) -> tuple[ObsType, dict[str, Any]]: + return self.env.reset(**kwargs) + + def __getattr__(self, item: str) -> Any: + return getattr(self.env, item) + + +class PatchedActionWrapper(PatchedWrapper, ABC): + def __init__(self, env: Env[ObsType, ActType]): + super().__init__(env) + + def step( + self, + action: ActType, + ) -> tuple[ObsType, SupportsFloat, bool, bool, dict[str, Any]]: + return self.env.step(self.action(action)) + + @abstractmethod + def action(self, action: ActType) -> np.ndarray: + pass + + class PatchedFrameStackObservation(Wrapper): def __init__( self, - env: gym.core.Env[ObsType, ActType], + env: Env[ObsType, ActType], stack_size: int, *, padding_type: str | ObsType = "reset", @@ -128,26 +164,116 @@ def __getattr__(self, item: str) -> Any: return getattr(self.env, item) -class ArmscanEnvFactory(EnvFactory): - """:param name2volume: the gymnasium task/environment identifier - :param observation: the observation to use - :param reward_metric: the reward metric to use - :param termination_criterion: the termination criterion to use - :param slice_shape: the shape of the slice - :param max_episode_len: the maximum episode length - :param rotation_bounds: the bounds for the angles - :param translation_bounds: the bounds for the translations - :param render_mode_train: the render mode to use for training environments - :param render_mode_test: the render mode to use for test environments - :param render_mode_watch: the render mode to use for environments that are used to watch agent performance - :param venv_type: the type of vectorized environment to use - :param seed: the seed to use - :param n_stack: the number of observations to stack in a single observation - :param project_to_x_translation: constrains the action space to only x translation - :param remove_rotation_actions: removes the rotation actions from the action space - :param make_kwargs: additional keyword arguments to pass to the environment creation function +class AddObservationsWrapper(Wrapper, ABC): + """When implementing it, make sure that additional_obs_space is available + before super().__init__(env) is called. """ + def __init__(self, env: LabelmapEnv | Env, additional_obs: Observation): + super().__init__(env) + self.additional_obs = additional_obs + if isinstance(self.env.observation_space, Box) and isinstance( + self.additional_obs_space, + Box, + ): + self.observation_space = ConcatenatedArrayObservation.concatenate_boxes( + [self.env.observation_space, self.additional_obs_space], + ) + else: + raise ValueError( + f"Observation spaces are not of type Box: {type(self.env.observation_space)}, {type(self.additional_obs_space)}", + ) + + @property + @abstractmethod + def additional_obs_space(self) -> gym.spaces: + pass + + @abstractmethod + def get_additional_obs( + self, + ) -> np.ndarray: + pass + + def observation( + self, + observation: np.ndarray, + ) -> np.ndarray: + additional_obs = self.get_additional_obs() + try: + full_obs = np.concatenate([observation, additional_obs]) + except ValueError: + raise ValueError( + f"Observation spaces are not of type Box: {type(observation)}, {type(additional_obs)}", + ) from None + return full_obs + + +class AddRewardDetailsWrapper(AddObservationsWrapper): + @property + def additional_obs_space(self) -> gym.spaces: + return self._additional_obs_space + + def __init__( + self, + env: LabelmapEnv | Env[ObsType, ActType], + num_steps_to_observe: int | None = None, + additional_obs: Observation = ActionRewardObservation().to_array_observation(), + ): + """Adds the action that would lead to the highest image variance to the observation. + In focus-stigmation agents, this helps in the initial exploratory phase of episodes, as it + allows wandering around the state space without worrying about losing track of the + best image found so far. + + :param env: + :param num_steps_to_observe: Number of steps to observe to pick the highest reward state. + If None, all steps are observed. + """ + self._additional_obs_space = additional_obs.observation_space + # don't move above, see comment in AddObservationsWrapper + super().__init__(env, additional_obs) + self.num_steps_to_observe = num_steps_to_observe + + self.reset_wrapper() + + def reset_wrapper(self) -> None: + if self.num_steps_to_observe is None: + self.rewards: list[float] = [] + self.states: list[LabelmapStateAction] = [] + else: + self.rewards = deque(maxlen=self.num_steps_to_observe) # type: ignore + self.states = deque(maxlen=self.num_steps_to_observe) # type: ignore + + def reset(self, **kwargs: Any) -> tuple[ObsType, dict[str, Any]]: + self.reset_wrapper() + obs, info = super().reset(**kwargs) + updated_obs = cast(ObsType, self.observation(obs)) + return updated_obs, info + + def step( + self, + action: ActType, + ) -> tuple[ObsType, SupportsFloat, bool, bool, dict[str, Any]]: + obs, reward, terminated, truncated, info = self.env.step(action) + updated_obs = cast(ObsType, self.observation(obs)) + return updated_obs, reward, terminated, truncated, info + + def _update_observation_fields(self) -> None: + tissue_clusters = TissueClusters.from_labelmap_slice( + self.env.cur_state_action.labels_2d_slice, + ) + clustering_reward = anatomy_based_rwd(tissue_clusters=tissue_clusters) + self.rewards.append(clustering_reward) + self.states.append(self.env.cur_state_action) + self.highest_rew_state_arr = self.states[np.argmax(self.rewards)] + + def get_additional_obs(self) -> np.ndarray: + # base_obs is not used, instead we directly access the current image from the env + self._update_observation_fields() + return self.additional_obs.compute_observation(self.highest_rew_state_arr) + + +class ArmscanEnvFactory(EnvFactory): def __init__( self, name2volume: dict[str, sitk.Image], @@ -167,8 +293,28 @@ def __init__( n_stack: int = 1, project_actions_to: Literal["x", "y", "xy"] | None = None, apply_volume_transformation: bool = False, + add_reward_details: bool = False, **make_kwargs: Any, ) -> None: + """:param name2volume: the gymnasium task/environment identifier + :param observation: the observation to use + :param reward_metric: the reward metric to use + :param termination_criterion: the termination criterion to use + :param slice_shape: the shape of the slice + :param max_episode_len: the maximum episode length + :param rotation_bounds: the bounds for the angles + :param translation_bounds: the bounds for the translations + :param render_mode_train: the render mode to use for training environments + :param render_mode_test: the render mode to use for test environments + :param render_mode_watch: the render mode to use for environments that are used to watch agent performance + :param venv_type: the type of vectorized environment to use + :param seed: the seed to use + :param n_stack: the number of observations to stack in a single observation + :param project_actions_to: constrains the action space to only x translation + :param apply_volume_transformation: whether to apply transformations to the volume for data augmentation + :param add_reward_details: whether to add reward details to the observation + :param make_kwargs: additional keyword arguments to pass to the environment creation function + """ super().__init__(venv_type) self.name2volume = name2volume self.observation = observation @@ -187,6 +333,7 @@ def __init__( self.n_stack = n_stack self.project_actions_to = project_actions_to self.apply_volume_transformation = apply_volume_transformation + self.add_reward_details = add_reward_details self.make_kwargs = make_kwargs def _create_kwargs(self) -> dict: @@ -216,35 +363,13 @@ def create_env(self, mode: EnvMode) -> LabelmapEnv: apply_volume_transformation=self.apply_volume_transformation, ) + if self.add_reward_details: + env = AddRewardDetailsWrapper( + env, + additional_obs=ActionRewardObservation( + action_shape=env.action_space.shape, + ).to_array_observation(), + ) if self.n_stack > 1: env = PatchedFrameStackObservation(env, self.n_stack) return env - - -# Todo: Issue on gymnasium for not overwriting reset method -class PatchedWrapper(Wrapper[np.ndarray, float, np.ndarray, np.ndarray]): - def __init__(self, env: LabelmapEnv | Env): - super().__init__(env) - # Helps with IDE autocompletion - self.env = cast(LabelmapEnv, env) - - def reset(self, **kwargs: Any) -> tuple[np.ndarray, dict[str, Any]]: - return self.env.reset(**kwargs) - - def __getattr__(self, item: str) -> Any: - return getattr(self.env, item) - - -class PatchedActionWrapper(PatchedWrapper, ABC): - def __init__(self, env: Env[ObsType, ActType]): - super().__init__(env) - - def step( - self, - action: ActType, - ) -> tuple[ObsType, SupportsFloat, bool, bool, dict[str, Any]]: - return self.env.step(self.action(action)) - - @abstractmethod - def action(self, action: ActType) -> np.ndarray: - pass From 59bb759bfb8d073ffb6c3fb04903f045e6bb57f5 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 19:11:41 +0200 Subject: [PATCH 06/36] fix test slicing --- test/armscan_env/test_labelmap_volumes.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index 5a99cb5..ea926ff 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -5,6 +5,7 @@ import SimpleITK as sitk from armscan_env.clustering import TissueLabel from armscan_env.config import get_config +from armscan_env.envs.state_action import ManipulatorAction from armscan_env.volumes.slicing import slice_volume config = get_config() @@ -41,7 +42,10 @@ def test_labelmap_properly_sliced(labelmaps): sliced_volume = slice_volume( volume=labelmap, slice_shape=slice_shape, - y_trans=-labelmap.GetOrigin()[1], + action=ManipulatorAction( + rotation=(0.0, 0.0), + translation=(0.0, labelmap.GetOrigin()[1]), + ), ) sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] assert not np.all(sliced_img == 0) From 7c93fd3350ca4ac2d55496373e50d8e7605d64be Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 19:47:47 +0200 Subject: [PATCH 07/36] fix test slicing --- test/armscan_env/test_labelmap_volumes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index ea926ff..bbc8fe6 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -44,7 +44,7 @@ def test_labelmap_properly_sliced(labelmaps): slice_shape=slice_shape, action=ManipulatorAction( rotation=(0.0, 0.0), - translation=(0.0, labelmap.GetOrigin()[1]), + translation=(0.0, -labelmap.GetOrigin()[1]), ), ) sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] From 50c516f5f98ce17332ac8be84b87954a5b0bcc6d Mon Sep 17 00:00:00 2001 From: Carlo Cagnetta Date: Fri, 28 Jun 2024 17:51:38 +0000 Subject: [PATCH 08/36] fix experiment parameters --- scripts/armscan_array_obs.py | 7 ++++--- scripts/armscan_dqn_sac_hl.py | 2 +- scripts/armscan_sac_hl.py | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/armscan_array_obs.py b/scripts/armscan_array_obs.py index 9073faa..cb8f78d 100644 --- a/scripts/armscan_array_obs.py +++ b/scripts/armscan_array_obs.py @@ -38,9 +38,9 @@ num_test_envs=1, buffer_size=100000, batch_size=256, - step_per_collect=10, - update_per_step=100, - start_timesteps=500, + step_per_collect=200, + update_per_step=2, + start_timesteps=5000, start_timesteps_random=True, ) @@ -61,6 +61,7 @@ termination_criterion=LabelmapEnvTerminationCriterion(min_reward_threshold=-0.1), reward_metric=LabelmapClusteringBasedReward(), project_actions_to="y", + apply_volume_transformation=True ) experiment = ( diff --git a/scripts/armscan_dqn_sac_hl.py b/scripts/armscan_dqn_sac_hl.py index 22abe1e..24e9699 100644 --- a/scripts/armscan_dqn_sac_hl.py +++ b/scripts/armscan_dqn_sac_hl.py @@ -35,7 +35,7 @@ num_epochs=1, step_per_epoch=1000000, num_train_envs=-1, - num_test_envs=10, + num_test_envs=1, buffer_size=1000000, batch_size=256, step_per_collect=200, diff --git a/scripts/armscan_sac_hl.py b/scripts/armscan_sac_hl.py index aff37fd..e3b8e58 100644 --- a/scripts/armscan_sac_hl.py +++ b/scripts/armscan_sac_hl.py @@ -33,8 +33,8 @@ sampling_config = SamplingConfig( num_epochs=1, step_per_epoch=1000000, - num_train_envs=-1, - num_test_envs=10, + num_train_envs=40, + num_test_envs=1, buffer_size=1000000, batch_size=256, step_per_collect=200, From 4eafefb50f1595c4fe5626663d55c8e769ab8b5d Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 20:31:04 +0200 Subject: [PATCH 09/36] Fixed slicing manipulator action in env --- docs/02_notebooks/L5_linear_sweep.ipynb | 50 ++++++++++---------- pyproject.toml | 2 +- scripts/armscan_array_obs.py | 10 ++-- src/armscan_env/envs/labelmaps_navigation.py | 36 ++++---------- src/armscan_env/envs/state_action.py | 6 +-- src/armscan_env/volumes/slicing.py | 2 +- 6 files changed, 44 insertions(+), 62 deletions(-) diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index c4ab64d..956e2fe 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -5,17 +5,18 @@ "execution_count": null, "id": "a4e98c0276b6012d", "metadata": {}, + "outputs": [], "source": [ "%load_ext autoreload\n", "%autoreload 2" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "50b440b37fd9414b", "metadata": {}, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", @@ -36,8 +37,7 @@ "from tianshou.highlevel.env import EnvMode\n", "\n", "config = get_config()" - ], - "outputs": [] + ] }, { "cell_type": "markdown", @@ -52,6 +52,7 @@ "execution_count": null, "id": "9ed46c7b", "metadata": {}, + "outputs": [], "source": [ "def walk_through_env(\n", " env: LabelmapEnv,\n", @@ -122,27 +123,27 @@ "\n", " if show:\n", " plt.show()" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "da45ed45bb7b8f3b", "metadata": {}, + "outputs": [], "source": [ "volume_1 = sitk.ReadImage(config.get_labels_path(1))\n", "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", "img_array_1 = sitk.GetArrayFromImage(volume_1)\n", "img_array_2 = sitk.GetArrayFromImage(volume_2)" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "63dd92db3829d7db", "metadata": {}, + "outputs": [], "source": [ "volume_size = volume_1.GetSize()\n", "\n", @@ -158,41 +159,41 @@ " render_mode=\"animation\",\n", " n_stack=2,\n", ").create_env(EnvMode.WATCH)" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "16a139f61aaafd19", "metadata": {}, + "outputs": [], "source": [ "env_rollout = walk_through_env(env, 10)\n", "\n", "plot_rollout_rewards(env_rollout)" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "6cdf855cc85a743a", "metadata": {}, + "outputs": [], "source": [ "env.get_cur_animation_as_html()" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "519dde5f1cea8a5f", "metadata": {}, + "outputs": [], "source": [ "volume_size = volume_1.GetSize()\n", "\n", "projected_env = ArmscanEnvFactory(\n", - " name2volume={\"1\": volume_1},\n", + " name2volume={\"2\": volume_2},\n", " observation=ActionRewardObservation(action_shape=(1,)).to_array_observation(),\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " reward_metric=LabelmapClusteringBasedReward(),\n", @@ -205,14 +206,14 @@ " project_actions_to=\"y\",\n", " apply_volume_transformation=True,\n", ").create_env(EnvMode.WATCH)" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "22877ab71fed2eb0", "metadata": {}, + "outputs": [], "source": [ "projected_env_rollout = walk_through_env(\n", " projected_env,\n", @@ -220,40 +221,39 @@ " render_title=\"Projected labelmap slice\",\n", ")\n", "plot_rollout_rewards(projected_env_rollout)" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "c2779884526e0716", "metadata": {}, + "outputs": [], "source": [ "print(\n", " \"Observed 'rewards': \\n\",\n", " [round(obs[1][-1], 4) for obs in projected_env_rollout.observations],\n", ")\n", "print(\"Env rewards: \\n\", [round(r, 4) for r in projected_env_rollout.rewards])" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "6ada94c94fe77de0", "metadata": {}, + "outputs": [], "source": [ "projected_env.get_cur_animation_as_html()" - ], - "outputs": [] + ] }, { "cell_type": "code", "execution_count": null, "id": "4fb82c1487521b11", "metadata": {}, - "source": [], - "outputs": [] + "outputs": [], + "source": [] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index 8dbee5c..9644114 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -154,7 +154,7 @@ lint = ["_black_check", "_ruff_check", "_ruff_check_nb"] _poetry_install_sort_plugin = "poetry self add poetry-plugin-sort" _poetry_sort = "poetry sort" clean-nbs = "python docs/nbstripout.py" -format = ["_black_format", "_ruff_format", "_ruff_format_nb"] +format = ["_black_format", "_ruff_format", "_ruff_format_nb", "_poetry_install_sort_plugin", "_poetry_sort"] _autogen_rst = "python docs/autogen_rst.py" _sphinx_build = "sphinx-build -W -b html docs docs/_build" _jb_generate_toc = "python docs/create_toc.py" diff --git a/scripts/armscan_array_obs.py b/scripts/armscan_array_obs.py index cb8f78d..77210ff 100644 --- a/scripts/armscan_array_obs.py +++ b/scripts/armscan_array_obs.py @@ -33,14 +33,14 @@ sampling_config = SamplingConfig( num_epochs=10, - step_per_epoch=100000, - num_train_envs=-1, + step_per_epoch=10, + num_train_envs=1, num_test_envs=1, - buffer_size=100000, + buffer_size=10, batch_size=256, step_per_collect=200, update_per_step=2, - start_timesteps=5000, + start_timesteps=1, start_timesteps_random=True, ) @@ -61,7 +61,7 @@ termination_criterion=LabelmapEnvTerminationCriterion(min_reward_threshold=-0.1), reward_metric=LabelmapClusteringBasedReward(), project_actions_to="y", - apply_volume_transformation=True + apply_volume_transformation=True, ) experiment = ( diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 81f72fe..2b550cd 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -16,7 +16,7 @@ from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.envs.state_action import LabelmapStateAction, ManipulatorAction from armscan_env.util.visualizations import show_clusters -from armscan_env.volumes.slicing import slice_volume, transform_volume +from armscan_env.volumes.slicing import EulerTransform, slice_volume, transform_volume from celluloid import Camera from IPython.core.display import HTML from matplotlib import pyplot as plt @@ -243,10 +243,7 @@ def _get_slice_from_action(self, action: np.ndarray | ManipulatorAction) -> np.n sliced_volume = slice_volume( volume=self.cur_labelmap_volume, slice_shape=self._slice_shape, - z_rotation=manipulator_action.rotation[0], - x_rotation=manipulator_action.rotation[1], - x_trans=manipulator_action.translation[0], - y_trans=manipulator_action.translation[1], + action=manipulator_action, ) return sitk.GetArrayFromImage(sliced_volume)[:, 0, :].T @@ -272,32 +269,17 @@ def apply_volume_transformation( volume: sitk.Image, optimal_action: ManipulatorAction, ) -> (sitk.Image, ManipulatorAction): # type: ignore - small_random_z_rotation = np.random.uniform(-20, 20) - small_random_x_rotation = np.random.uniform(-5, 5) - small_random_x_translation = np.random.uniform(-25, 25) - small_random_y_translation = np.random.uniform(-5, 5) - transformed_optimal_action = ManipulatorAction( - rotation=( - optimal_action.rotation[0] + small_random_z_rotation, - optimal_action.rotation[1] + small_random_x_rotation, - ), - translation=( - optimal_action.translation[0], - optimal_action.translation[1] + small_random_y_translation, - ), + volume_transformation = ManipulatorAction( + rotation=(np.random.uniform(-20, 20), np.random.uniform(-5, 5)), + translation=(np.random.uniform(-5, 5), np.random.uniform(-5, 5)), + ) + transformed_optimal_action = EulerTransform(volume_transformation).transform_action( + optimal_action, ) - if self.rotation_bounds: - bounds = list(self.rotation_bounds) - bounds[0] += abs(small_random_z_rotation) - bounds[1] += abs(small_random_x_rotation) - self.rotation_bounds = tuple(bounds) # type: ignore return ( transform_volume( volume=volume, - z_rotation=small_random_z_rotation, - x_rotation=small_random_x_rotation, - x_trans=small_random_x_translation, - y_trans=small_random_y_translation, + action=volume_transformation, ), transformed_optimal_action, ) diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index defcf90..495863b 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -31,7 +31,7 @@ def to_normalized_array( rotation = np.zeros(2) translation = np.zeros(2) if self.translation[0] < 0 or self.translation[1] < 0: - log.info("Projecting to positive because negative defined translation") + log.debug("Projecting to positive because negative defined translation") self.project_to_positive() for i in range(2): if rotation_bounds[i] == 0.0: @@ -84,7 +84,7 @@ def project_to_positive(self) -> None: """Project the action to the positive octant.""" tx, ty = self.translation thz, thx = self.rotation - log.info(f"Translation before projection: {self.translation}") + log.debug(f"Translation before projection: {self.translation}") while tx < 0 or ty < 0: if tx < 0: ty = (np.tan(np.deg2rad(thz)) * (-tx)) + ty @@ -93,7 +93,7 @@ def project_to_positive(self) -> None: tx = ((1 / np.tan(np.deg2rad(thz))) * (-ty)) + tx ty = 0 translation = (tx, ty) - log.info(f"Translation after projection: {translation}") + log.debug(f"Translation after projection: {translation}") self.translation = translation diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py index 6c85d25..604d90b 100644 --- a/src/armscan_env/volumes/slicing.py +++ b/src/armscan_env/volumes/slicing.py @@ -102,7 +102,7 @@ def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAct translation=new_action_translation, ) - log.info( + log.debug( f"Random transformation: {self.action}\n" f"Original action: {relative_action}\n" f"Transformed action: {transformed_action}\n", From d5f02994a6a20999d1f60b5d9484fdc183d207a6 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 28 Jun 2024 20:33:57 +0200 Subject: [PATCH 10/36] Add by mistake --- scripts/armscan_array_obs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/armscan_array_obs.py b/scripts/armscan_array_obs.py index 77210ff..8d57958 100644 --- a/scripts/armscan_array_obs.py +++ b/scripts/armscan_array_obs.py @@ -33,14 +33,14 @@ sampling_config = SamplingConfig( num_epochs=10, - step_per_epoch=10, - num_train_envs=1, + step_per_epoch=100000, + num_train_envs=-1, num_test_envs=1, - buffer_size=10, + buffer_size=100000, batch_size=256, step_per_collect=200, update_per_step=2, - start_timesteps=1, + start_timesteps=5000, start_timesteps_random=True, ) From 5050cdbaf3f08e6c107d9af3b96ec5297c6c24ee Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Sun, 30 Jun 2024 19:02:26 +0200 Subject: [PATCH 11/36] fix optimal action 2 --- src/armscan_env/envs/labelmaps_navigation.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 2b550cd..456ee99 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -32,7 +32,7 @@ _VOL_NAME_TO_OPTIMAL_ACTION = { "1": ManipulatorAction(rotation=(19.3, 0.0), translation=(0.0, 140.0)), - "2": ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 115.0)), + "2": ManipulatorAction(rotation=(5, 0), translation=(0, 112)), } @@ -168,8 +168,8 @@ def get_optimal_action_array(self) -> np.ndarray: full_action_arr = self.get_full_optimal_action_array() return full_action_arr[self._get_projected_action_arr_idx()] - def step_to_optimal_state(self) -> None: - self.step(self.get_optimal_action()) + def step_to_optimal_state(self) -> tuple[TObs, float, bool, bool, dict[str, Any]]: + return self.step(self.get_optimal_action()) @property def cur_labelmap_name(self) -> str | None: @@ -276,6 +276,11 @@ def apply_volume_transformation( transformed_optimal_action = EulerTransform(volume_transformation).transform_action( optimal_action, ) + if self.rotation_bounds: + bounds = list(self.rotation_bounds) + bounds[0] += abs(volume_transformation.rotation[0]) + bounds[1] += abs(volume_transformation.rotation[1]) + self.rotation_bounds = tuple(bounds) # type: ignore return ( transform_volume( volume=volume, @@ -426,7 +431,9 @@ def get_cur_state_plot( iz = volume.GetSize()[2] // 2 ax1.imshow(img_array[iz, :, :]) x_dash = np.arange(img_array.shape[2]) - b = volume.TransformPhysicalPointToIndex([o[0], o[1] + translation[1], o[2]])[1] + b = volume.TransformPhysicalPointToIndex( + [o[0] + translation[0], o[1] + translation[1], o[2]], + )[1] b_x = b + np.tan(np.deg2rad(rotation[1])) * iz y_dash = np.tan(np.deg2rad(rotation[0])) * x_dash + b_x y_dash = np.clip(y_dash, 0, img_array.shape[1] - 1) From 1a259e2ae217b27c7515968b96f67599a3089b14 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Sun, 30 Jun 2024 20:45:26 +0200 Subject: [PATCH 12/36] transforming optimal action, and referencing back when slicing --- docs/02_notebooks/L4_environment.ipynb | 20 +++--- scripts/random_volume_transformations.ipynb | 73 ++++++++------------- src/armscan_env/volumes/slicing.py | 59 +++++++++++------ 3 files changed, 76 insertions(+), 76 deletions(-) diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index ea4f95d..54bab90 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -37,6 +37,7 @@ "from armscan_env.clustering import TissueClusters\n", "from armscan_env.config import get_config\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", + "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", "from armscan_env.volumes.slicing import slice_volume\n", "from IPython.core.display import HTML\n", @@ -110,8 +111,7 @@ " sliced_volume = slice_volume(\n", " volume=volume_1,\n", " slice_shape=slice_shape,\n", - " z_rotation=z[i],\n", - " y_trans=t[i],\n", + " action=ManipulatorAction(rotation=(z[i], 0.0), translation=(0.0, t[i])),\n", " )\n", " sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :].T\n", " ax2.imshow(sliced_img.T, aspect=6, origin=\"lower\")\n", @@ -195,14 +195,19 @@ " termination_criterion=LabelmapEnvTerminationCriterion(),\n", " max_episode_len=10,\n", " rotation_bounds=(30.0, 10.0),\n", - " translation_bounds=(0.0, None),\n", + " translation_bounds=(None, None),\n", " render_mode=\"animation\",\n", + " apply_volume_transformation=True,\n", ")\n", "\n", "observation, info = env.reset()\n", "for _ in range(50):\n", " action = env.action_space.sample()\n", - " observation, reward, terminated, truncated, info = env.step(action)\n", + " epsilon = 0.1\n", + " if np.random.rand() > epsilon:\n", + " observation, reward, terminated, truncated, info = env.step(action)\n", + " else:\n", + " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", " env.render()\n", "\n", " if terminated or truncated:\n", @@ -219,13 +224,6 @@ "source": [ "HTML(animation.to_jshtml())" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/scripts/random_volume_transformations.ipynb b/scripts/random_volume_transformations.ipynb index 1931c7d..4f802d4 100644 --- a/scripts/random_volume_transformations.ipynb +++ b/scripts/random_volume_transformations.ipynb @@ -55,39 +55,19 @@ "metadata": {}, "cell_type": "code", "source": [ - "volume.GetDirection()" - ], - "id": "dab795a261809b07", - "outputs": [], - "execution_count": null - }, - { - "metadata": {}, - "cell_type": "code", - "source": [ - "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(5, 0))\n", - "transformed_volume = transform_volume(volume, volume_transformation)\n", - "\n", - "volume_transformation_matrix = EulerTransform(volume_transformation).get_transform()\n", - "print(f\"Volume transformation: \\n{volume_transformation_matrix}\")\n", - "# the action must be transformed by the inverse of the volume transformation\n", - "action_transformation_matrix = np.linalg.inv(volume_transformation_matrix)\n", - "print(f\"Action transformation: \\n{action_transformation_matrix}\")\n", - "transformed_action = EulerTransform(volume_transformation).transform_action(action)\n", - "print(f\"Transformed action: {transformed_action}\")\n", - "\n", - "transformed_img = sitk.GetArrayFromImage(transformed_volume)\n", - "plt.imshow(transformed_img[40, :, :])\n", - "\n", - "ot = transformed_volume.GetOrigin()\n", - "x_dash = np.arange(transformed_img.shape[2])\n", - "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + transformed_action.translation[1], o[2]])[1]\n", - "y_dash = x_dash * np.tan(np.deg2rad(transformed_action.rotation[0])) + b\n", - "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "sliced_volume = slice_volume(\n", + " action=action,\n", + " volume=volume,\n", + " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", + ")\n", + "sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", + "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", "\n", + "slice = sliced_img\n", + "plt.imshow(slice, aspect=6)\n", "plt.show()" ], - "id": "309c559805bcb3fe", + "id": "cb9c333a74781d5a", "outputs": [], "execution_count": null }, @@ -95,9 +75,11 @@ "metadata": {}, "cell_type": "code", "source": [ - "volume.GetDirection()" + "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(15, 15))\n", + "transformed_volume = transform_volume(volume, volume_transformation)\n", + "transformed_action = EulerTransform(volume_transformation).transform_action(action)" ], - "id": "c4ec806cd695481f", + "id": "26ffcc6d7dece611", "outputs": [], "execution_count": null }, @@ -105,19 +87,18 @@ "metadata": {}, "cell_type": "code", "source": [ - "sliced_volume = slice_volume(\n", - " action=action,\n", - " volume=volume,\n", - " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", - ")\n", - "sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", - "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", + "transformed_img = sitk.GetArrayFromImage(transformed_volume)\n", + "plt.imshow(transformed_img[40, :, :])\n", + "\n", + "ot = transformed_volume.GetOrigin()\n", + "x_dash = np.arange(transformed_img.shape[2])\n", + "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + transformed_action.translation[1], o[2]])[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(transformed_action.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", - "slice = sliced_img\n", - "plt.imshow(slice, aspect=6)\n", "plt.show()" ], - "id": "cb9c333a74781d5a", + "id": "db20a3ff9556e8b4", "outputs": [], "execution_count": null }, @@ -149,12 +130,12 @@ "volume_2_img = sitk.GetArrayFromImage(volume_2)\n", "spacing = volume_2.GetSpacing()\n", "plt.imshow(volume_2_img[51, :, :])\n", - "action = ManipulatorAction(rotation=(5, 0), translation=(0, 112))\n", + "action_2 = ManipulatorAction(rotation=(5, 0), translation=(0, 112))\n", "\n", "o = volume_2.GetOrigin()\n", "x_dash = np.arange(volume_2_img.shape[2])\n", - "b = volume_2.TransformPhysicalPointToIndex([o[0], o[1] + action.translation[1], o[2]])[1]\n", - "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", + "b = volume_2.TransformPhysicalPointToIndex([o[0], o[1] + action_2.translation[1], o[2]])[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(action_2.rotation[0])) + b\n", "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", "plt.show()" @@ -168,7 +149,7 @@ "cell_type": "code", "source": [ "sliced_volume_2 = slice_volume(\n", - " action=action,\n", + " action=action_2,\n", " volume=volume_2,\n", " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", ")\n", diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py index 604d90b..24d7a14 100644 --- a/src/armscan_env/volumes/slicing.py +++ b/src/armscan_env/volumes/slicing.py @@ -38,8 +38,10 @@ def padding(original_array: np.ndarray) -> np.ndarray: ) # Verify the shapes - print("Original Array Shape:", original_array.shape) - print("Padded Array Shape:", padded_array.shape) + log.debug( + f"Original Array Shape: {original_array.shape}\n" + f"Padded Array Shape: {padded_array.shape}", + ) return padded_array @@ -93,12 +95,17 @@ def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAct volume_transform_matrix = self.get_transform() action_matrix = EulerTransform(relative_action).get_transform() - # new_action_matrix = np.dot(np.linalg.inv(volume_transform_matrix), action_matrix) # 1_A_s = 1_T_0 * 0_A_s + new_action_matrix = np.dot( + np.linalg.inv(volume_transform_matrix), + action_matrix, + ) # 1_A_s = 1_T_0 * 0_A_s - new_action_translation = action_matrix[:2, 3] - volume_transform_matrix[:2, 3] + # new_action_translation = action_matrix[:2, 3] - volume_transform_matrix[:2, 3] + new_action_rotation = self.get_angles_from_rotation_matrix(new_action_matrix[:3, :3]) + new_action_translation = new_action_matrix[:2, 3] transformed_action = ManipulatorAction( - rotation=relative_action.rotation, + rotation=new_action_rotation, translation=new_action_translation, ) @@ -111,10 +118,16 @@ def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAct return transformed_action +class TransformedVolume(sitk.Image): + def __init__(self, volume: sitk.Image, action: ManipulatorAction): + super().__init__(volume) + self.transformation_action = action + + def transform_volume( volume: sitk.Image, action: ManipulatorAction, -) -> sitk.Image: +) -> TransformedVolume: """Trasnform a 3D volume with arbitrary rotation and translation. :param volume: 3D volume to be transformed @@ -142,15 +155,14 @@ def transform_volume( resampler.SetSize(volume.GetSize()) resampler.SetInterpolator(sitk.sitkNearestNeighbor) - # Todo: hack --> needed to break rotation dependency - volume.transformation_action = action # type: ignore - # Resample the volume on the arbitrary plane - return resampler.Execute(volume) + transformed_volume = resampler.Execute(volume) + # Todo: hack --> needed to break rotation dependency + return TransformedVolume(transformed_volume, action) def slice_volume( - volume: sitk.Image, + volume: sitk.Image | TransformedVolume, slice_shape: tuple[int, int], action: ManipulatorAction, ) -> sitk.Image: @@ -162,17 +174,26 @@ def slice_volume( :return: the sliced volume. """ o = np.array(volume.GetOrigin()) + + # Todo: hack --> the action attribute set in transform_volume; find a better solution later + if hasattr(volume, "transformation_action"): + volume_transformation = EulerTransform(volume.transformation_action).get_transform() + action_transformation = EulerTransform(action).get_transform() + detransformed_action = np.dot(volume_transformation, action_transformation) + action_rotation = EulerTransform.get_angles_from_rotation_matrix( + detransformed_action[:3, :3], + ) + action_translation = (detransformed_action[:3, 3] - volume_transformation[:3, 3])[:2] + action = ManipulatorAction(rotation=action_rotation, translation=(action_translation)) + euler_transform = EulerTransform(action, o) eul_tr = euler_transform.get_transform() # Define plane's coordinate system - e1 = eul_tr[0][:3] - e2 = eul_tr[1][:3] - e3 = eul_tr[2][:3] - img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane + rotation = eul_tr[:3, :3] + translation = eul_tr[:, -1:].flatten()[:3] # origin of the image plane - # Todo: hack --> the action attribute set in transform_volume; find a better solution later - direction = np.stack([e1, e2, e3], axis=0).flatten() + rotation = rotation.flatten() resampler = sitk.ResampleImageFilter() spacing = volume.GetSpacing() @@ -180,8 +201,8 @@ def slice_volume( w = slice_shape[0] h = slice_shape[1] - resampler.SetOutputDirection(direction.tolist()) - resampler.SetOutputOrigin(img_o.tolist()) + resampler.SetOutputDirection(rotation.tolist()) + resampler.SetOutputOrigin(translation.tolist()) resampler.SetOutputSpacing(spacing) resampler.SetSize((w, 3, h)) resampler.SetInterpolator(sitk.sitkNearestNeighbor) From d54aaf37cdb915a564d1d009d42592ef5e90bae8 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 1 Jul 2024 11:01:43 +0200 Subject: [PATCH 13/36] Actions: improve triggers --- .github/workflows/lint_and_docs.yaml | 20 ++++++++++++++++++-- .github/workflows/pytest.yml | 8 +++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint_and_docs.yaml b/.github/workflows/lint_and_docs.yaml index 1b9cf34..0547125 100644 --- a/.github/workflows/lint_and_docs.yaml +++ b/.github/workflows/lint_and_docs.yaml @@ -1,7 +1,19 @@ name: PEP8, Types and Docs Check -on: [push, pull_request] - +on: + pull_request: + branches: + - main + push: + branches: + - main + workflow_dispatch: + inputs: + debug_enabled: + type: boolean + description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' + required: false + default: false jobs: check: runs-on: ubuntu-latest @@ -12,6 +24,10 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: + # Enable tmate debugging of manually-triggered workflows if the input option was provided + - name: Setup tmate session + uses: mxschmitt/action-tmate@v3 + if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} - name: Cancel previous run uses: styfle/cancel-workflow-action@0.11.0 with: diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 88adf48..591c14f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -1,6 +1,12 @@ name: Ubuntu -on: [push, pull_request] +on: + pull_request: + branches: + - main + push: + branches: + - main jobs: cpu: From a6c69585b50df4b3b942e9a3359ea063bb6d72e1 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Mon, 1 Jul 2024 13:21:35 +0200 Subject: [PATCH 14/36] Added a safety feature to TransformedVolume Renamed some functions and added TODOs --- docs/02_notebooks/L2_DBSCAN_clustering.ipynb | 63 ++++++++++----- docs/02_notebooks/L3_slicing.ipynb | 27 ++++--- docs/02_notebooks/L4_environment.ipynb | 6 +- docs/spelling_wordlist.txt | 2 + src/armscan_env/envs/labelmaps_navigation.py | 12 ++- src/armscan_env/volumes/slicing.py | 85 +++++++++++++------- test/armscan_env/test_labelmap_volumes.py | 4 +- 7 files changed, 127 insertions(+), 72 deletions(-) diff --git a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb index 81caf20..ed39620 100644 --- a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb +++ b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb @@ -207,18 +207,23 @@ "zero_loss_indices = np.where(np.array(sweep_loss) == 0)[0]\n", "print(f\"{len(zero_loss_indices)} indices return a zero loss: \", zero_loss_indices)\n", "\n", - "fig, axes = plt.subplots(2, 4, figsize=(21, 7))\n", - "axes = axes.flatten()\n", - "for i, idx in enumerate(zero_loss_indices):\n", - " axes[i] = show_clusters(\n", - " tissue_clusters=clusters_list[idx],\n", - " slice=mri_1_label_data[:, idx, :].T,\n", - " aspect=6,\n", - " ax=axes[i],\n", - " )\n", - " axes[i].set_title(f\"Index: {idx}, Loss: {sweep_loss[idx]:.2f}\")\n", + "nrows = 2\n", + "ncols = len(zero_loss_indices) // nrows\n", + "indices_to_display = nrows * ncols\n", "\n", - "plt.show()" + "if indices_to_display > 0:\n", + " fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=(21, 7))\n", + " axes = axes.flatten()\n", + " for i, idx in enumerate(zero_loss_indices[:indices_to_display]):\n", + " axes[i] = show_clusters(\n", + " tissue_clusters=clusters_list[idx],\n", + " slice=mri_1_label_data[:, idx, :].T,\n", + " aspect=6,\n", + " ax=axes[i],\n", + " )\n", + " axes[i].set_title(f\"Index: {idx}, Loss: {sweep_loss[idx]:.2f}\")\n", + "\n", + " plt.show()" ] }, { @@ -289,22 +294,36 @@ "metadata": {}, "outputs": [], "source": [ + "# TODO: reduce duplication with printing above, move to a function\n", + "\n", "zero_loss_indices = np.where(np.array(sweep_loss) == 0)[0]\n", "print(f\"{len(zero_loss_indices)} indices return a zero loss: \", zero_loss_indices)\n", "\n", - "fig, axes = plt.subplots(2, 4, figsize=(21, 7))\n", - "axes = axes.flatten()\n", - "for i, idx in enumerate(zero_loss_indices):\n", - " axes[i] = show_clusters(\n", - " tissue_clusters=zero_loss_clusters[i],\n", - " slice=mri_1_label_data[:, idx, :].T,\n", - " aspect=6,\n", - " ax=axes[i],\n", - " )\n", - " axes[i].set_title(f\"Index: {idx}, Loss: {sweep_loss[idx]:.2f}\")\n", + "nrows = 2\n", + "ncols = len(zero_loss_indices) // nrows\n", + "indices_to_display = nrows * ncols\n", "\n", - "plt.show()" + "if indices_to_display > 0:\n", + " fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=(21, 7))\n", + " axes = axes.flatten()\n", + " for i, idx in enumerate(zero_loss_indices[:indices_to_display]):\n", + " axes[i] = show_clusters(\n", + " tissue_clusters=zero_loss_clusters[i],\n", + " slice=mri_1_label_data[:, idx, :].T,\n", + " aspect=6,\n", + " ax=axes[i],\n", + " )\n", + " axes[i].set_title(f\"Index: {idx}, Loss: {sweep_loss[idx]:.2f}\")\n", + "\n", + " plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/docs/02_notebooks/L3_slicing.ipynb b/docs/02_notebooks/L3_slicing.ipynb index ee1b4da..2a816b3 100644 --- a/docs/02_notebooks/L3_slicing.ipynb +++ b/docs/02_notebooks/L3_slicing.ipynb @@ -34,7 +34,13 @@ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import SimpleITK as sitk\n", + "from armscan_env.clustering import TissueClusters\n", "from armscan_env.config import get_config\n", + "from armscan_env.envs.rewards import anatomy_based_rwd\n", + "from armscan_env.envs.state_action import ManipulatorAction\n", + "from armscan_env.util.visualizations import show_clusters\n", + "from armscan_env.volumes.slicing import get_volume_slice\n", + "from celluloid import Camera\n", "from IPython.core.display import HTML\n", "\n", "config = get_config()" @@ -164,7 +170,6 @@ "# (cosine of the angle between the normal vector and the x axis: z-rotation)\n", "w = int(abs(volume_size[0] // e1[0]))\n", "\n", - "\n", "print(f\" {h=},\\n {w=}\")" ] }, @@ -252,10 +257,7 @@ "metadata": {}, "outputs": [], "source": [ - "from armscan_env.envs.state_action import ManipulatorAction\n", - "from armscan_env.volumes.slicing import slice_volume\n", - "\n", - "sliced_volume = slice_volume(\n", + "sliced_volume = get_volume_slice(\n", " action=ManipulatorAction(rotation=(19.3, 0), translation=(0, 140)),\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", @@ -274,10 +276,6 @@ "metadata": {}, "outputs": [], "source": [ - "from armscan_env.clustering import TissueClusters\n", - "from armscan_env.envs.rewards import anatomy_based_rwd\n", - "from armscan_env.util.visualizations import show_clusters\n", - "\n", "clusters = TissueClusters.from_labelmap_slice(sliced_img.T)\n", "show_clusters(clusters, sliced_img.T)\n", "reward = anatomy_based_rwd(clusters, (4, 2, 1))\n", @@ -292,8 +290,6 @@ }, "outputs": [], "source": [ - "from celluloid import Camera\n", - "\n", "# Demonstration of arbitrary slicing\n", "t = [160, 155, 150, 148, 146, 142, 140, 140, 115, 120, 125, 125, 130, 130, 135, 138, 140, 140, 140]\n", "z = [0, -5, 0, 0, 5, 15, 19.3, -10, 0, 0, 0, 5, -8, 8, 0, -10, -10, 10, 19.3]\n", @@ -318,7 +314,7 @@ " ax1.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", " # Subplot 2: Function image\n", - " sliced_volume = slice_volume(\n", + " sliced_volume = get_volume_slice(\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", " action=ManipulatorAction(\n", @@ -335,6 +331,13 @@ "animation = camera.animate()\n", "HTML(animation.to_jshtml())" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index 54bab90..3d2b7e0 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -39,7 +39,7 @@ "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.slicing import slice_volume\n", + "from armscan_env.volumes.slicing import get_volume_slice\n", "from IPython.core.display import HTML\n", "\n", "config = get_config()" @@ -49,7 +49,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We can now put everything together in a single environment. We will use the `slice_volume` function to create a 2D slice of the 3D volume, and then we will use the `find_DBSCAN_clusters` function to find the clusters of pixels that correspond to the different tissues. Finally, we will use the `anatomy_based_rwd` function to calculate the reward based on the anatomy of the arm." + "We can now put everything together in a single environment. We will use the `get_volume_slice` function to create a 2D slice of the 3D volume, and then we will use the `find_DBSCAN_clusters` function to find the clusters of pixels that correspond to the different tissues. Finally, we will use the `anatomy_based_rwd` function to calculate the reward based on the anatomy of the arm." ] }, { @@ -108,7 +108,7 @@ " ax1.set_title(\"Slice cut\")\n", "\n", " # ACTION\n", - " sliced_volume = slice_volume(\n", + " sliced_volume = get_volume_slice(\n", " volume=volume_1,\n", " slice_shape=slice_shape,\n", " action=ManipulatorAction(rotation=(z[i], 0.0), translation=(0.0, t[i])),\n", diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index daaeacc..2c121c5 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -60,3 +60,5 @@ plt mlp config env +octant +octants diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 456ee99..90bee57 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -16,7 +16,11 @@ from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.envs.state_action import LabelmapStateAction, ManipulatorAction from armscan_env.util.visualizations import show_clusters -from armscan_env.volumes.slicing import EulerTransform, slice_volume, transform_volume +from armscan_env.volumes.slicing import ( + EulerTransform, + create_transformed_volume, + get_volume_slice, +) from celluloid import Camera from IPython.core.display import HTML from matplotlib import pyplot as plt @@ -240,7 +244,7 @@ def _get_slice_from_action(self, action: np.ndarray | ManipulatorAction) -> np.n manipulator_action = self.get_manipulator_action_from_normalized_action(action) else: manipulator_action = action - sliced_volume = slice_volume( + sliced_volume = get_volume_slice( volume=self.cur_labelmap_volume, slice_shape=self._slice_shape, action=manipulator_action, @@ -282,9 +286,9 @@ def apply_volume_transformation( bounds[1] += abs(volume_transformation.rotation[1]) self.rotation_bounds = tuple(bounds) # type: ignore return ( - transform_volume( + create_transformed_volume( volume=volume, - action=volume_transformation, + transformation_action=volume_transformation, ), transformed_optimal_action, ) diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py index 24d7a14..1be6df5 100644 --- a/src/armscan_env/volumes/slicing.py +++ b/src/armscan_env/volumes/slicing.py @@ -47,11 +47,13 @@ def padding(original_array: np.ndarray) -> np.ndarray: class EulerTransform: - def __init__(self, action: ManipulatorAction, origin: np.ndarray = np.zeros(3)): + def __init__(self, action: ManipulatorAction, origin: np.ndarray | None = None): + if origin is None: + origin = np.zeros(3) self.action = action self.origin = origin - def get_transform(self) -> np.ndarray: + def get_transform_matrix(self) -> np.ndarray: # Euler's transformation # Rotation is defined by three rotations around z1, x2, z2 axis th_z1 = np.deg2rad(self.action.rotation[0]) @@ -92,9 +94,9 @@ def get_angles_from_rotation_matrix(rotation_matrix: np.ndarray) -> np.ndarray: def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: """Transform an action to be relative to the new coordinate system.""" - volume_transform_matrix = self.get_transform() + volume_transform_matrix = self.get_transform_matrix() - action_matrix = EulerTransform(relative_action).get_transform() + action_matrix = EulerTransform(relative_action).get_transform_matrix() new_action_matrix = np.dot( np.linalg.inv(volume_transform_matrix), action_matrix, @@ -119,30 +121,48 @@ def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAct class TransformedVolume(sitk.Image): - def __init__(self, volume: sitk.Image, action: ManipulatorAction): - super().__init__(volume) - self.transformation_action = action + """Represents a volume that has been transformed by an action. + Should only ever be instantiated by `create_transformed_volume`. + """ + + def __init__(self, *args, transformation_action: ManipulatorAction, _private: int): + if _private != 42: + raise ValueError( + "TransformedVolume should only be instantiated by create_transformed_volume.", + ) + super().__init__(*args) + self._transformation_action = transformation_action + + @property + def transformation_action(self) -> ManipulatorAction | None: + return self._transformation_action -def transform_volume( + +def create_transformed_volume( volume: sitk.Image, - action: ManipulatorAction, + transformation_action: ManipulatorAction, ) -> TransformedVolume: - """Trasnform a 3D volume with arbitrary rotation and translation. + """Transform a 3D volume with arbitrary rotation and translation. :param volume: 3D volume to be transformed - :param action: action to transform the volume + :param transformation_action: action to transform the volume :return: the sliced volume. """ + if isinstance(volume, TransformedVolume): + raise ValueError( + f"This operation should only be performed on a non-transformed volume " + f"but got an instance of: {volume.__class__.__name__}.", + ) + origin = np.array(volume.GetOrigin()) - euler_transform = EulerTransform(action, origin) - eul_tr = euler_transform.get_transform() + transform_matrix = EulerTransform(transformation_action, origin).get_transform_matrix() # Define plane's coordinate system - e1 = eul_tr[0][:3] - e2 = eul_tr[1][:3] - e3 = eul_tr[2][:3] - img_o = eul_tr[:, -1:].flatten()[:3] # origin of the image plane + e1 = transform_matrix[0][:3] + e2 = transform_matrix[1][:3] + e3 = transform_matrix[2][:3] + img_o = transform_matrix[:, -1:].flatten()[:3] # origin of the image plane direction = np.stack([e1, e2, e3], axis=0).flatten() @@ -157,12 +177,17 @@ def transform_volume( # Resample the volume on the arbitrary plane transformed_volume = resampler.Execute(volume) - # Todo: hack --> needed to break rotation dependency - return TransformedVolume(transformed_volume, action) + # needed to deal with rotation dependency of the volume + return TransformedVolume( + transformed_volume, + transformation_action=transformation_action, + _private=42, + ) -def slice_volume( - volume: sitk.Image | TransformedVolume, +def get_volume_slice( + volume: sitk.Image, + # TODO: shouldn't there be a default shape, like the native shape of the volume itself? slice_shape: tuple[int, int], action: ManipulatorAction, ) -> sitk.Image: @@ -175,19 +200,21 @@ def slice_volume( """ o = np.array(volume.GetOrigin()) - # Todo: hack --> the action attribute set in transform_volume; find a better solution later - if hasattr(volume, "transformation_action"): - volume_transformation = EulerTransform(volume.transformation_action).get_transform() - action_transformation = EulerTransform(action).get_transform() - detransformed_action = np.dot(volume_transformation, action_transformation) + if isinstance(volume, TransformedVolume): + # TODO: why is origin not used here? + volume_transformation = EulerTransform(volume.transformation_action).get_transform_matrix() + action_transformation = EulerTransform(action).get_transform_matrix() + # TODO: why not use action_transformation.transform_action ? + inverse_transformed_action = np.dot(volume_transformation, action_transformation) action_rotation = EulerTransform.get_angles_from_rotation_matrix( - detransformed_action[:3, :3], + inverse_transformed_action[:3, :3], ) - action_translation = (detransformed_action[:3, 3] - volume_transformation[:3, 3])[:2] + action_translation = (inverse_transformed_action[:3, 3] - volume_transformation[:3, 3])[:2] action = ManipulatorAction(rotation=action_rotation, translation=(action_translation)) + # TODO: seems to be recomputed as action_transformation in block above - can it be computed once instead? euler_transform = EulerTransform(action, o) - eul_tr = euler_transform.get_transform() + eul_tr = euler_transform.get_transform_matrix() # Define plane's coordinate system rotation = eul_tr[:3, :3] diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index bbc8fe6..90db539 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -6,7 +6,7 @@ from armscan_env.clustering import TissueLabel from armscan_env.config import get_config from armscan_env.envs.state_action import ManipulatorAction -from armscan_env.volumes.slicing import slice_volume +from armscan_env.volumes.slicing import get_volume_slice config = get_config() @@ -39,7 +39,7 @@ def test_all_tissue_labels_present(labelmaps): def test_labelmap_properly_sliced(labelmaps): for labelmap, _i in labelmaps: slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) - sliced_volume = slice_volume( + sliced_volume = get_volume_slice( volume=labelmap, slice_shape=slice_shape, action=ManipulatorAction( From f005d7f3c87f0a24db6aed108ebf6c0e17a16c1c Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Mon, 1 Jul 2024 20:01:41 +0200 Subject: [PATCH 15/36] small notebook fixes --- docs/02_notebooks/L2_DBSCAN_clustering.ipynb | 9 +-------- docs/02_notebooks/L3_slicing.ipynb | 7 ------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb index ed39620..416de6a 100644 --- a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb +++ b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb @@ -248,7 +248,7 @@ "\n", "for i in range(mri_1_label_data.shape[1]):\n", " clusters = TissueClusters.from_labelmap_slice(mri_1_label_data[:, i, :].T)\n", - " loss = anatomy_based_rwd(clusters, n_landmarks=[7, 2, 1])\n", + " loss = anatomy_based_rwd(clusters, n_landmarks=[7, 3, 1])\n", " if loss == 0:\n", " zero_loss_clusters.append(clusters)\n", " print(f\"Loss for slice {i}: {loss}\")\n", @@ -317,13 +317,6 @@ "\n", " plt.show()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/02_notebooks/L3_slicing.ipynb b/docs/02_notebooks/L3_slicing.ipynb index 2a816b3..689052a 100644 --- a/docs/02_notebooks/L3_slicing.ipynb +++ b/docs/02_notebooks/L3_slicing.ipynb @@ -331,13 +331,6 @@ "animation = camera.animate()\n", "HTML(animation.to_jshtml())" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { From 569cb653148ce0ad6d81783d69b2058ae6513c57 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Mon, 1 Jul 2024 20:39:23 +0200 Subject: [PATCH 16/36] Add notebooks folder --- .../random_volume_transformations.ipynb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) rename {scripts => notebooks}/random_volume_transformations.ipynb (94%) diff --git a/scripts/random_volume_transformations.ipynb b/notebooks/random_volume_transformations.ipynb similarity index 94% rename from scripts/random_volume_transformations.ipynb rename to notebooks/random_volume_transformations.ipynb index 4f802d4..b0d1e52 100644 --- a/scripts/random_volume_transformations.ipynb +++ b/notebooks/random_volume_transformations.ipynb @@ -22,7 +22,7 @@ "from armscan_env.clustering import TissueClusters\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.slicing import EulerTransform, slice_volume, transform_volume\n", + "from armscan_env.volumes.slicing import EulerTransform, get_volume_slice, create_transformed_volume\n", "\n", "config = config.get_config()" ], @@ -55,7 +55,7 @@ "metadata": {}, "cell_type": "code", "source": [ - "sliced_volume = slice_volume(\n", + "sliced_volume = get_volume_slice(\n", " action=action,\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", @@ -76,7 +76,7 @@ "cell_type": "code", "source": [ "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(15, 15))\n", - "transformed_volume = transform_volume(volume, volume_transformation)\n", + "transformed_volume = create_transformed_volume(volume, volume_transformation)\n", "transformed_action = EulerTransform(volume_transformation).transform_action(action)" ], "id": "26ffcc6d7dece611", @@ -106,7 +106,7 @@ "metadata": {}, "cell_type": "code", "source": [ - "sliced_transformed_volume = slice_volume(\n", + "sliced_transformed_volume = get_volume_slice(\n", " action=transformed_action,\n", " volume=transformed_volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", @@ -148,7 +148,7 @@ "metadata": {}, "cell_type": "code", "source": [ - "sliced_volume_2 = slice_volume(\n", + "sliced_volume_2 = get_volume_slice(\n", " action=action_2,\n", " volume=volume_2,\n", " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", From b72f0edad4a0d7e5ead2a5d3bbeffc30c9f47b12 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 11:29:52 +0200 Subject: [PATCH 17/36] ToDo: caching --- notebooks/random_volume_transformations.ipynb | 6 +++++- src/armscan_env/clustering.py | 4 ++++ src/armscan_env/envs/rewards.py | 1 + src/armscan_env/volumes/slicing.py | 3 ++- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/notebooks/random_volume_transformations.ipynb b/notebooks/random_volume_transformations.ipynb index b0d1e52..557b9a8 100644 --- a/notebooks/random_volume_transformations.ipynb +++ b/notebooks/random_volume_transformations.ipynb @@ -22,7 +22,11 @@ "from armscan_env.clustering import TissueClusters\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.slicing import EulerTransform, get_volume_slice, create_transformed_volume\n", + "from armscan_env.volumes.slicing import (\n", + " EulerTransform,\n", + " create_transformed_volume,\n", + " get_volume_slice,\n", + ")\n", "\n", "config = config.get_config()" ], diff --git a/src/armscan_env/clustering.py b/src/armscan_env/clustering.py index 3cc543a..63a5151 100644 --- a/src/armscan_env/clustering.py +++ b/src/armscan_env/clustering.py @@ -37,6 +37,8 @@ class DataCluster: datapoints: list[tuple[float, float]] | np.ndarray center: tuple[np.floating[Any], np.floating[Any]] + # ToDo: Make a custom __hash__ method for the class that deals with lists + @dataclass(kw_only=True) class TissueClusters: @@ -46,6 +48,8 @@ class TissueClusters: tendons: list[DataCluster] ulnar: list[DataCluster] + # ToDo: Make a custom __hash__ method for the class that deals with lists + def get_cluster_for_label(self, label: TissueLabel) -> list[DataCluster]: """Get the clusters for a given tissue label.""" match label: diff --git a/src/armscan_env/envs/rewards.py b/src/armscan_env/envs/rewards.py index 0b53b28..b2866ab 100644 --- a/src/armscan_env/envs/rewards.py +++ b/src/armscan_env/envs/rewards.py @@ -10,6 +10,7 @@ log = logging.getLogger(__name__) +# ToDo: make a cache for the function def anatomy_based_rwd( tissue_clusters: TissueClusters, n_landmarks: Sequence[int] = (4, 2, 1), diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py index 1be6df5..f363f11 100644 --- a/src/armscan_env/volumes/slicing.py +++ b/src/armscan_env/volumes/slicing.py @@ -1,4 +1,5 @@ import logging +from typing import Any import numpy as np import SimpleITK as sitk @@ -126,7 +127,7 @@ class TransformedVolume(sitk.Image): Should only ever be instantiated by `create_transformed_volume`. """ - def __init__(self, *args, transformation_action: ManipulatorAction, _private: int): + def __init__(self, *args: Any, transformation_action: ManipulatorAction, _private: int): if _private != 42: raise ValueError( "TransformedVolume should only be instantiated by create_transformed_volume.", From 11ffb0779858f6fea1e145e0c50a8bae9b138712 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 11:30:32 +0200 Subject: [PATCH 18/36] add notebooks to poe tasks --- pyproject.toml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9644114..1aa6a10 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,12 +144,12 @@ PYDEVD_DISABLE_FILE_VALIDATION="1" test = "pytest test --cov=armscan_env --cov-report=xml --cov-report=term-missing --durations=0 -v --color=yes" # Adjust to a smaller set of tests if appropriate test-subset = "pytest test --color=yes" -_black_check = "black --check src scripts docs" -_ruff_check = "ruff check src scripts docs" -_ruff_check_nb = "nbqa ruff docs" -_black_format = "black src scripts docs" -_ruff_format = "ruff --fix src scripts docs" -_ruff_format_nb = "nbqa ruff --fix docs" +_black_check = "black --check src scripts docs notebooks" +_ruff_check = "ruff check src scripts docs notebooks" +_ruff_check_nb = "nbqa ruff docs notebooks" +_black_format = "black src scripts docs notebooks" +_ruff_format = "ruff --fix src scripts docs notebooks" +_ruff_format_nb = "nbqa ruff --fix docs notebooks" lint = ["_black_check", "_ruff_check", "_ruff_check_nb"] _poetry_install_sort_plugin = "poetry self add poetry-plugin-sort" _poetry_sort = "poetry sort" From 683ed473346e5fdc080fe9eee6303713c5015931 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 12:03:42 +0200 Subject: [PATCH 19/36] changed random volume transformation to take action as parameter --- src/armscan_env/envs/labelmaps_navigation.py | 34 +++++++++++++------- src/armscan_env/envs/state_action.py | 32 ++++++++++++++++-- 2 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 90bee57..95758af 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -172,7 +172,9 @@ def get_optimal_action_array(self) -> np.ndarray: full_action_arr = self.get_full_optimal_action_array() return full_action_arr[self._get_projected_action_arr_idx()] - def step_to_optimal_state(self) -> tuple[TObs, float, bool, bool, dict[str, Any]]: + def step_to_optimal_state( + self, + ) -> tuple[Observation[LabelmapStateAction, Any], float, bool, bool, dict[str, Any]]: return self.step(self.get_optimal_action()) @property @@ -271,24 +273,30 @@ def compute_next_state( def apply_volume_transformation( self, volume: sitk.Image, + volume_transformation_action: ManipulatorAction, optimal_action: ManipulatorAction, ) -> (sitk.Image, ManipulatorAction): # type: ignore - volume_transformation = ManipulatorAction( - rotation=(np.random.uniform(-20, 20), np.random.uniform(-5, 5)), - translation=(np.random.uniform(-5, 5), np.random.uniform(-5, 5)), - ) - transformed_optimal_action = EulerTransform(volume_transformation).transform_action( + """Apply a random transformation to the volume and to the optimal action. The transformation is a random rotation + and translation. The bounds of the rotation are updated if they have already been set. The translation bounds are + computed from the volume size in the 'sample_initial_state' method. + + :param volume: the volume to transform + :param volume_transformation_action: the transformation action to apply to the volume + :param optimal_action: the optimal action for the volume to transform accordingly + :return: the transformed volume and the transformed optimal action + """ + transformed_optimal_action = EulerTransform(volume_transformation_action).transform_action( optimal_action, ) if self.rotation_bounds: bounds = list(self.rotation_bounds) - bounds[0] += abs(volume_transformation.rotation[0]) - bounds[1] += abs(volume_transformation.rotation[1]) + bounds[0] += abs(volume_transformation_action.rotation[0]) + bounds[1] += abs(volume_transformation_action.rotation[1]) self.rotation_bounds = tuple(bounds) # type: ignore return ( create_transformed_volume( volume=volume, - transformation_action=volume_transformation, + transformation_action=volume_transformation_action, ), transformed_optimal_action, ) @@ -303,9 +311,11 @@ def sample_initial_state(self) -> LabelmapStateAction: volume_optimal_action = deepcopy(_VOL_NAME_TO_OPTIMAL_ACTION[sampled_image_name]) if self._apply_volume_transformation: + volume_transformation_action = ManipulatorAction.sample() self._cur_labelmap_volume, self._cur_optimal_action = self.apply_volume_transformation( - self.name2volume[sampled_image_name], - volume_optimal_action, + volume=self.name2volume[sampled_image_name], + volume_transformation_action=volume_transformation_action, + optimal_action=volume_optimal_action, ) else: self._cur_labelmap_volume = self.name2volume[sampled_image_name] @@ -361,7 +371,7 @@ def get_cur_manipulator_action(self) -> ManipulatorAction: def step( self, action: np.ndarray | ManipulatorAction, - ) -> tuple[TObs, float, bool, bool, dict[str, Any]]: + ) -> tuple[Observation[LabelmapStateAction, Any], float, bool, bool, dict[str, Any]]: if isinstance(action, ManipulatorAction): action = action.to_normalized_array(self.rotation_bounds, self.translation_bounds) return super().step(action) diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index 495863b..271ab52 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -31,7 +31,10 @@ def to_normalized_array( rotation = np.zeros(2) translation = np.zeros(2) if self.translation[0] < 0 or self.translation[1] < 0: - log.debug("Projecting to positive because negative defined translation") + log.debug( + "Action contains a negative translation, out of bounds.\n" + "Projecting the origin of the viewing plane to positive octant.", + ) self.project_to_positive() for i in range(2): if rotation_bounds[i] == 0.0: @@ -81,7 +84,16 @@ def from_normalized_array( return cls(rotation=tuple(rotation), translation=tuple(translation)) # type: ignore def project_to_positive(self) -> None: - """Project the action to the positive octant.""" + """Project the action to the positive octant. + This is needed when transformin the optimal action accordingly to the random volume transformation. + It might be, that for a negative translation and/or a negative z-rotation, the coordinates defining the + optimal action land in negative space. Since the action defines a coordinate frame which infers a plane + (x-z plane, y normal to the plane), assuming that this plane is still intercepting the positive octant, + it is possible to redefine the action in positive coordinates by projecting it into the positive octant. + + It needs to be tested, that the volume transformations keep the optimal action in a reachable space. + Volume transformations are used for data augmentation only, so can be defined in the most convenient way. + """ tx, ty = self.translation thz, thx = self.rotation log.debug(f"Translation before projection: {self.translation}") @@ -96,6 +108,22 @@ def project_to_positive(self) -> None: log.debug(f"Translation after projection: {translation}") self.translation = translation + @classmethod + def sample( + cls, + rotation_range: tuple[float, float] = (20.0, 5.0), + translation_range: tuple[float, float] = (5.0, 5.0), + ) -> Self: + rotation = ( + np.random.uniform(-rotation_range[0], rotation_range[0]), + np.random.uniform(-rotation_range[1], rotation_range[1]), + ) + translation = ( + np.random.uniform(-translation_range[0], translation_range[0]), + np.random.uniform(-translation_range[1], translation_range[1]), + ) + return cls(rotation=rotation, translation=translation) + @dataclass(kw_only=True) class LabelmapStateAction(StateAction): From 0c0bbdecef0996c5bafc18b1bae711d71f71d56e Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 12:27:29 +0200 Subject: [PATCH 20/36] spelling --- src/armscan_env/envs/state_action.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index 271ab52..60c6bd5 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -85,7 +85,7 @@ def from_normalized_array( def project_to_positive(self) -> None: """Project the action to the positive octant. - This is needed when transformin the optimal action accordingly to the random volume transformation. + This is needed when transforming the optimal action accordingly to the random volume transformation. It might be, that for a negative translation and/or a negative z-rotation, the coordinates defining the optimal action land in negative space. Since the action defines a coordinate frame which infers a plane (x-z plane, y normal to the plane), assuming that this plane is still intercepting the positive octant, From eecf76b22189905f2fc6b3fca6616d6ea0300e7e Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 17:12:44 +0200 Subject: [PATCH 21/36] using Framestack before AddRewardDetails, including flatte observation wrapper --- src/armscan_env/wrapper.py | 59 ++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/src/armscan_env/wrapper.py b/src/armscan_env/wrapper.py index 50dc6fd..cdda124 100644 --- a/src/armscan_env/wrapper.py +++ b/src/armscan_env/wrapper.py @@ -19,7 +19,10 @@ LabelmapEnv, LabelmapEnvTerminationCriterion, ) -from armscan_env.envs.observations import ActionRewardObservation, MultiBoxSpace +from armscan_env.envs.observations import ( + ActionRewardObservation, + MultiBoxSpace, +) from armscan_env.envs.rewards import LabelmapClusteringBasedReward, anatomy_based_rwd from armscan_env.envs.state_action import LabelmapStateAction @@ -164,14 +167,42 @@ def __getattr__(self, item: str) -> Any: return getattr(self.env, item) +class PatchedFlattenObservation(PatchedWrapper): + """Flattens the environment's observation space and each observation from ``reset`` and ``step`` functions. + Had to copy-paste and adjust. + """ + + def __init__(self, env: Env[ObsType, ActType]): + PatchedWrapper.__init__(self, env) + observation_space = gym.spaces.utils.flatten_space(env.observation_space) + func = lambda obs: gym.spaces.utils.flatten(env.observation_space, obs) + if observation_space is not None: + self.observation_space = observation_space + self.func = func + + def reset(self, **kwargs: Any) -> tuple[ObsType, dict[str, Any]]: + obs, info = self.env.reset(**kwargs) + return self.observation(obs), info + + def step( + self, + action: ActType, + ) -> tuple[ObsType, SupportsFloat, bool, bool, dict[str, Any]]: + observation, reward, terminated, truncated, info = self.env.step(action) + return self.observation(observation), reward, terminated, truncated, info + + def observation(self, observation: ObsType) -> Any: + """Apply function to the observation.""" + return self.func(observation) + + class AddObservationsWrapper(Wrapper, ABC): """When implementing it, make sure that additional_obs_space is available before super().__init__(env) is called. """ - def __init__(self, env: LabelmapEnv | Env, additional_obs: Observation): + def __init__(self, env: LabelmapEnv | Env): super().__init__(env) - self.additional_obs = additional_obs if isinstance(self.env.observation_space, Box) and isinstance( self.additional_obs_space, Box, @@ -190,7 +221,7 @@ def additional_obs_space(self) -> gym.spaces: pass @abstractmethod - def get_additional_obs( + def get_additional_obs_array( self, ) -> np.ndarray: pass @@ -199,7 +230,7 @@ def observation( self, observation: np.ndarray, ) -> np.ndarray: - additional_obs = self.get_additional_obs() + additional_obs = self.get_additional_obs_array() try: full_obs = np.concatenate([observation, additional_obs]) except ValueError: @@ -216,9 +247,8 @@ def additional_obs_space(self) -> gym.spaces: def __init__( self, - env: LabelmapEnv | Env[ObsType, ActType], + env: LabelmapEnv, num_steps_to_observe: int | None = None, - additional_obs: Observation = ActionRewardObservation().to_array_observation(), ): """Adds the action that would lead to the highest image variance to the observation. In focus-stigmation agents, this helps in the initial exploratory phase of episodes, as it @@ -229,9 +259,10 @@ def __init__( :param num_steps_to_observe: Number of steps to observe to pick the highest reward state. If None, all steps are observed. """ - self._additional_obs_space = additional_obs.observation_space + self.additional_obs = ActionRewardObservation(env.action_space.shape).to_array_observation() + self._additional_obs_space = self.additional_obs.observation_space # don't move above, see comment in AddObservationsWrapper - super().__init__(env, additional_obs) + super().__init__(env) self.num_steps_to_observe = num_steps_to_observe self.reset_wrapper() @@ -267,7 +298,7 @@ def _update_observation_fields(self) -> None: self.states.append(self.env.cur_state_action) self.highest_rew_state_arr = self.states[np.argmax(self.rewards)] - def get_additional_obs(self) -> np.ndarray: + def get_additional_obs_array(self) -> np.ndarray: # base_obs is not used, instead we directly access the current image from the env self._update_observation_fields() return self.additional_obs.compute_observation(self.highest_rew_state_arr) @@ -363,13 +394,11 @@ def create_env(self, mode: EnvMode) -> LabelmapEnv: apply_volume_transformation=self.apply_volume_transformation, ) + if self.n_stack > 1: + env = PatchedFrameStackObservation(env, self.n_stack) + env = PatchedFlattenObservation(env) if self.add_reward_details: env = AddRewardDetailsWrapper( env, - additional_obs=ActionRewardObservation( - action_shape=env.action_space.shape, - ).to_array_observation(), ) - if self.n_stack > 1: - env = PatchedFrameStackObservation(env, self.n_stack) return env From 517421d6e872f9f1ddc8de2ab39b42c6c92abfa5 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Wed, 3 Jul 2024 17:18:52 +0200 Subject: [PATCH 22/36] script for debugging --- scripts/armscan_debugging.py | 90 ++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 scripts/armscan_debugging.py diff --git a/scripts/armscan_debugging.py b/scripts/armscan_debugging.py new file mode 100644 index 0000000..085af1b --- /dev/null +++ b/scripts/armscan_debugging.py @@ -0,0 +1,90 @@ +import logging +import os + +import SimpleITK as sitk +from armscan_env.config import get_config +from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion +from armscan_env.envs.observations import ( + ActionRewardObservation, + LabelmapClusterObservation, +) +from armscan_env.envs.rewards import LabelmapClusteringBasedReward +from armscan_env.wrapper import ArmscanEnvFactory + +from tianshou.highlevel.config import SamplingConfig +from tianshou.highlevel.env import VectorEnvType +from tianshou.highlevel.experiment import ( + ExperimentConfig, + SACExperimentBuilder, +) +from tianshou.highlevel.params.alpha import AutoAlphaFactoryDefault +from tianshou.highlevel.params.policy_params import SACParams +from tianshou.utils.logging import datetime_tag + +if __name__ == "__main__": + config = get_config() + logging.basicConfig(level=logging.INFO) + + volume_1 = sitk.ReadImage(config.get_labels_path(1)) + volume_2 = sitk.ReadImage(config.get_labels_path(2)) + + log_name = os.path.join("sac-characteristic-array", str(ExperimentConfig.seed), datetime_tag()) + experiment_config = ExperimentConfig() + + sampling_config = SamplingConfig( + num_epochs=10, + step_per_epoch=100, + num_train_envs=1, + num_test_envs=1, + buffer_size=100, + batch_size=256, + step_per_collect=200, + update_per_step=2, + start_timesteps=50, + start_timesteps_random=True, + ) + + volume_size = volume_1.GetSize() + env_factory = ArmscanEnvFactory( + name2volume={"1": volume_1}, + observation=LabelmapClusterObservation() + .merged_with(other=ActionRewardObservation(action_shape=(1,))) # type: ignore + .to_array_observation(), + slice_shape=(volume_size[0], volume_size[2]), + max_episode_len=20, + rotation_bounds=(90.0, 45.0), + translation_bounds=(0.0, None), + render_mode="animation", + seed=experiment_config.seed, + venv_type=VectorEnvType.DUMMY, + n_stack=2, + termination_criterion=LabelmapEnvTerminationCriterion(min_reward_threshold=-0.1), + reward_metric=LabelmapClusteringBasedReward(), + project_actions_to="y", + apply_volume_transformation=True, + add_reward_details=True, + ) + + experiment = ( + SACExperimentBuilder(env_factory, experiment_config, sampling_config) + .with_sac_params( + SACParams( + tau=0.005, + gamma=0.99, + alpha=AutoAlphaFactoryDefault(lr=3e-4), + estimation_step=1, + actor_lr=1e-3, + critic1_lr=1e-3, + critic2_lr=1e-3, + ), + ) + .with_actor_factory_default( + (256, 256), + continuous_unbounded=True, + continuous_conditioned_sigma=True, + ) + .with_common_critic_factory_default((256, 256)) + .build() + ) + + experiment.run(run_name=log_name) From 972bd0506394dda6f88bedb4071bfa7fac6fe245 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 4 Jul 2024 14:55:44 +0200 Subject: [PATCH 23/36] loading volumes and standardizing volumes spacing --- docs/02_notebooks/L4_environment.ipynb | 35 +++--- notebooks/noramlized_volumes.ipynb | 158 +++++++++++++++++++++++++ src/armscan_env/config.py | 10 ++ src/armscan_env/volumes/loading.py | 67 +++++++++-- 4 files changed, 246 insertions(+), 24 deletions(-) create mode 100644 notebooks/noramlized_volumes.ipynb diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index 3d2b7e0..bd50169 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -39,6 +39,7 @@ "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", + "from armscan_env.volumes.loading import load_sitk_volumes\n", "from armscan_env.volumes.slicing import get_volume_slice\n", "from IPython.core.display import HTML\n", "\n", @@ -58,10 +59,9 @@ "metadata": {}, "outputs": [], "source": [ - "volume_1 = sitk.ReadImage(config.get_labels_path(1))\n", - "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", - "img_array_1 = sitk.GetArrayFromImage(volume_1)\n", - "img_array_2 = sitk.GetArrayFromImage(volume_2)" + "volumes = load_sitk_volumes(normalize=True)\n", + "img_array_1 = sitk.GetArrayFromImage(volumes[0])\n", + "img_array_2 = sitk.GetArrayFromImage(volumes[1])" ] }, { @@ -78,8 +78,8 @@ "\n", "t = [160, 155, 150, 148, 146, 142, 140, 140, 115, 120, 125, 125, 130, 130, 135, 138, 140, 140, 140]\n", "z = [0, -5, 0, 0, 5, 15, 19.3, -10, 0, 0, 0, 5, -8, 8, 0, -10, -10, 10, 19.3]\n", - "o = volume_1.GetOrigin()\n", - "slice_shape = (volume_1.GetSize()[0], volume_1.GetSize()[2])\n", + "o = volumes[0].GetOrigin()\n", + "slice_shape = (volumes[0].GetSize()[0], volumes[0].GetSize()[2])\n", "\n", "\n", "# Sample functions for demonstration\n", @@ -101,7 +101,7 @@ " # Subplot 1: Image with dashed line\n", " ax1.imshow(img_array_1[40, :, :])\n", " x_dash = np.arange(img_array_1.shape[2])\n", - " b = volume_1.TransformPhysicalPointToIndex([o[0], o[1] + t[i], o[2]])[1]\n", + " b = volumes[0].TransformPhysicalPointToIndex([o[0], o[1] + t[i], o[2]])[1]\n", " y_dash = linear_function(x_dash, np.tan(np.deg2rad(z[i])), b)\n", " ax1.set_title(f\"Section {0}\")\n", " line = ax1.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")[0]\n", @@ -109,7 +109,7 @@ "\n", " # ACTION\n", " sliced_volume = get_volume_slice(\n", - " volume=volume_1,\n", + " volume=volumes[0],\n", " slice_shape=slice_shape,\n", " action=ManipulatorAction(rotation=(z[i], 0.0), translation=(0.0, t[i])),\n", " )\n", @@ -158,10 +158,10 @@ "metadata": {}, "outputs": [], "source": [ - "origin = volume_1.GetOrigin()\n", - "spacing = volume_1.GetSpacing()\n", - "size = volume_1.GetSize()\n", - "end = volume_1.TransformIndexToPhysicalPoint(size)\n", + "origin = volumes[0].GetOrigin()\n", + "spacing = volumes[0].GetSpacing()\n", + "size = volumes[0].GetSize()\n", + "end = volumes[0].TransformIndexToPhysicalPoint(size)\n", "print(f\"{origin=},\\n {spacing=},\\n {end=}\")\n", "dim = np.subtract(end, origin)\n", "physical_size = size * np.array(spacing)\n", @@ -182,10 +182,10 @@ ")\n", "from armscan_env.envs.observations import LabelmapSliceAsChannelsObservation\n", "\n", - "volume_size = volume_1.GetSize()\n", + "volume_size = volumes[0].GetSize()\n", "\n", "env = LabelmapEnv(\n", - " name2volume={\"1\": volume_1, \"2\": volume_2},\n", + " name2volume={\"2\": volumes[1]},\n", " observation=LabelmapSliceAsChannelsObservation(\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " action_shape=(4,),\n", @@ -224,6 +224,13 @@ "source": [ "HTML(animation.to_jshtml())" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/notebooks/noramlized_volumes.ipynb b/notebooks/noramlized_volumes.ipynb new file mode 100644 index 0000000..c2d993b --- /dev/null +++ b/notebooks/noramlized_volumes.ipynb @@ -0,0 +1,158 @@ +{ + "cells": [ + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-04T12:32:31.264217Z", + "start_time": "2024-07-04T12:32:31.208456Z" + } + }, + "cell_type": "code", + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import SimpleITK as sitk\n", + "from armscan_env import config\n", + "from armscan_env.volumes.loading import load_sitk_volumes, resize_sitk_volume\n", + "\n", + "config = config.get_config()" + ], + "id": "ecaf94d658c47deb", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The autoreload extension is already loaded. To reload it, use:\n", + " %reload_ext autoreload\n" + ] + } + ], + "execution_count": 9 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-04T12:32:31.501529Z", + "start_time": "2024-07-04T12:32:31.266993Z" + } + }, + "cell_type": "code", + "source": [ + "volumes = load_sitk_volumes(normalize=False)" + ], + "id": "a468f5f6f4c63d26", + "outputs": [], + "execution_count": 10 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-04T12:32:31.953535Z", + "start_time": "2024-07-04T12:32:31.504431Z" + } + }, + "cell_type": "code", + "source": [ + "normalized_volumes = resize_sitk_volume(volumes)" + ], + "id": "185cb662e1b4c9cd", + "outputs": [], + "execution_count": 11 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-04T12:32:32.308226Z", + "start_time": "2024-07-04T12:32:31.958020Z" + } + }, + "cell_type": "code", + "source": [ + "array = sitk.GetArrayFromImage(normalized_volumes[1])\n", + "plt.imshow(array[40, :, :])\n", + "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" + ], + "id": "96a75fa203718430", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Slice value range: 0 - 4\n" + ] + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 12 + }, + { + "metadata": { + "ExecuteTime": { + "end_time": "2024-07-04T12:32:45.856184Z", + "start_time": "2024-07-04T12:32:45.597942Z" + } + }, + "cell_type": "code", + "source": [ + "array = sitk.GetArrayFromImage(volumes[0])\n", + "plt.imshow(array[40, :, :])\n", + "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" + ], + "id": "2f8c0a05160a98e3", + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Slice value range: 0 - 4\n" + ] + }, + { + "data": { + "text/plain": [ + "
" + ], + "image/png": "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m2++malTpwLg9Xqx2+2kpaWFlb3ynHq93k7P+aV9w9Err7zCjh07WLly5VX75Jz2zPHjx3nhhRcYP348b775Jo8//jhf/epXeemll4DL5+Va33+v10tOTk7YfqvVSkZGRlTndVBOOyZ6prS0lL179/LBBx/EuyqD2unTp/na177G+vXrSUhIiHd1hgzTNJk7dy4//OEPAZg1axZ79+5l1apVLF26tF/rMqhajFlZWVgslqt696qqqvB4PHGq1eCwbNky1q5dyzvvvMPIkSND2z0eD+3t7TQ0NISVv/KcejyeTs/5pX3DTXl5OdXV1cyePRur1YrVamXjxo0899xzWK1W3G63nNMeyM3NpaioKGzb5MmTqaioAC6fl2t9/z0eD9XV1WH7A4EAdXV1UZ3XQRWMdrudOXPmsGHDhtA20zTZsGEDxcXFcazZwKWUYtmyZbz22mu8/fbbFBYWhu2fM2cONpst7JweOnSIioqK0DktLi5mz549YX9w69evx+l0XvWHPBzcdttt7Nmzh127doVec+fOZcmSJaH/lnMavZtvvvmqoWSHDx9m1KhRABQWFuLxeMLOq8/nY8uWLWHntaGhgfLy8lCZt99+G9M0mTdvXvcrE33fUXy98soryuFwqNWrV6v9+/erxx57TKWlpYX17onLHn/8ceVyudS7776rKisrQ6/W1tZQmS9/+cuqoKBAvf3222r79u2quLhYFRcXh/ZfGlpy++23q127dql169ap7OzsYT20JNKVvdJKyTntia1btyqr1ap+8IMfqCNHjqiXX35ZJSUlqd/85jehMj/60Y9UWlqaev3119VHH32k7rnnnk6H68yaNUtt2bJFffDBB2r8+PFDf7iOUkr99Kc/VQUFBcput6sbb7xRbd68Od5VGrAIrqN41evFF18Mlblw4YL6h3/4B5Wenq6SkpLUfffdpyorK8OOc/LkSXXnnXeqxMRElZWVpb7+9a+rjo6Ofv40A1dkMMo57Zk//elPaurUqcrhcKhJkyapX/ziF2H7TdNU3/3ud5Xb7VYOh0Pddttt6tChQ2Flamtr1YMPPqhSUlKU0+lUX/ziF1VTU1NU9ZD5GIUQIsKguscohBD9QYJRCCEiSDAKIUQECUYhhIggwSiEEBEkGIUQIoIEoxBCRJBgFEKICBKMQggRQYJRCCEiSDAKIUSE/x+1kQUOTRWVgQAAAABJRU5ErkJggg==" + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "execution_count": 14 + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/armscan_env/config.py b/src/armscan_env/config.py index b0bd538..a6bba3b 100644 --- a/src/armscan_env/config.py +++ b/src/armscan_env/config.py @@ -19,6 +19,12 @@ def get_labels_basedir(self) -> str: check_existence=True, ) + def count_labels(self) -> int: + labels_dir = self.get_labels_basedir() + return len( + [f for f in os.listdir(labels_dir) if os.path.isfile(os.path.join(labels_dir, f))], + ) + def get_mri_path(self, mri_number: int) -> str: mri_path = os.path.join(self.get_mri_basedir(), f"{mri_number:05d}.nii") return self._adjusted_path(mri_path, relative=False, check_existence=True) @@ -30,6 +36,10 @@ def get_mri_basedir(self) -> str: check_existence=True, ) + def count_mri(self) -> int: + mri_dir = self.get_mri_basedir() + return len([f for f in os.listdir(mri_dir) if os.path.isfile(os.path.join(mri_dir, f))]) + class ConfigProvider(ConfigProviderBase[__Configuration]): pass diff --git a/src/armscan_env/volumes/loading.py b/src/armscan_env/volumes/loading.py index fb26d54..e212ff3 100644 --- a/src/armscan_env/volumes/loading.py +++ b/src/armscan_env/volumes/loading.py @@ -1,17 +1,64 @@ +import numpy as np import SimpleITK as sitk +from armscan_env.config import get_config +config = get_config() -def load_sitk_volume( - path: str, - spacing: tuple[float, float, float] | None = (0.5, 0.5, 1), -) -> sitk.Image: + +def resize_sitk_volume( + volumes: list[sitk.Image], # n_spacing: tuple[float, float, float], +) -> list[sitk.Image]: + """Resize a SimpleITK volume to a normalized spacing, and interpolate to get right amount of voxels. + Have a look at [this](https://stackoverflow.com/questions/48065117/simpleitk-resize-images) link to see potential problems. + + :param volumes: the volumes to resize + :param n_spacing: the normalized spacing to set + :return: the resized volume + """ + volumes[0].GetDimension() + + # Reference spacing will be the smallest spacing in the dataset + reference_spacing = np.min([volume.GetSpacing() for volume in volumes], axis=0) + + normalized_volumes = [] + for _i, volume in enumerate(volumes): + volume_physical_size = [ + sz * spc for sz, spc in zip(volume.GetSize(), volume.GetSpacing(), strict=True) + ] + # Size will be adjusted based on the new volume spacing + reference_size = [ + int(phys_sz / spc) + for phys_sz, spc in zip(volume_physical_size, reference_spacing, strict=True) + ] + + # Resample the image to the reference image + resampler = sitk.ResampleImageFilter() + resampler.SetReferenceImage(volume) + resampler.SetOutputSpacing(reference_spacing) + resampler.SetSize(reference_size) + resampler.SetOutputDirection(volume.GetDirection()) + resampler.SetOutputOrigin(volume.GetOrigin()) + resampler.SetInterpolator(sitk.sitkNearestNeighbor) + normalized_volumes.append(resampler.Execute(volume)) + + return normalized_volumes + + +def load_sitk_volumes( + normalize: bool = False, +) -> list[sitk.Image]: """Load a SimpleITK volume from a file. - :param path: path to the volume file - :param spacing: spacing of the volume + :param normalize: whether to normalize the volumes to a single spacing :return: the loaded volume """ - volume = sitk.ReadImage(path) - if spacing is not None: - volume.SetSpacing(spacing) - return volume + volumes = [] + # count how many nii files are under the path and load them with config.get_labels_patt + for label in range(1, config.count_labels() + 1): + volume = sitk.ReadImage(config.get_labels_path(label)) + volumes.append(volume) + + if normalize: + volumes = resize_sitk_volume(volumes) + + return volumes From a6ccc46083851b0efa69cfe130b5a31a7a65a9d7 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 4 Jul 2024 15:01:00 +0200 Subject: [PATCH 24/36] spelling --- docs/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 2c121c5..ebdd4e1 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -48,6 +48,7 @@ initialised rgb ansi voxel +voxels convolutional preprocessed subspaces From b57769ba8f81c04eed39129011b8751871c66020 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 4 Jul 2024 16:04:19 +0200 Subject: [PATCH 25/36] fixup! loading volumes and standardizing volumes spacing --- notebooks/noramlized_volumes.ipynb | 158 ----------------------------- notebooks/normalized_volumes.ipynb | 88 ++++++++++++++++ 2 files changed, 88 insertions(+), 158 deletions(-) delete mode 100644 notebooks/noramlized_volumes.ipynb create mode 100644 notebooks/normalized_volumes.ipynb diff --git a/notebooks/noramlized_volumes.ipynb b/notebooks/noramlized_volumes.ipynb deleted file mode 100644 index c2d993b..0000000 --- a/notebooks/noramlized_volumes.ipynb +++ /dev/null @@ -1,158 +0,0 @@ -{ - "cells": [ - { - "metadata": { - "ExecuteTime": { - "end_time": "2024-07-04T12:32:31.264217Z", - "start_time": "2024-07-04T12:32:31.208456Z" - } - }, - "cell_type": "code", - "source": [ - "%load_ext autoreload\n", - "%autoreload 2\n", - "\n", - "import matplotlib.pyplot as plt\n", - "import numpy as np\n", - "import SimpleITK as sitk\n", - "from armscan_env import config\n", - "from armscan_env.volumes.loading import load_sitk_volumes, resize_sitk_volume\n", - "\n", - "config = config.get_config()" - ], - "id": "ecaf94d658c47deb", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The autoreload extension is already loaded. To reload it, use:\n", - " %reload_ext autoreload\n" - ] - } - ], - "execution_count": 9 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2024-07-04T12:32:31.501529Z", - "start_time": "2024-07-04T12:32:31.266993Z" - } - }, - "cell_type": "code", - "source": [ - "volumes = load_sitk_volumes(normalize=False)" - ], - "id": "a468f5f6f4c63d26", - "outputs": [], - "execution_count": 10 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2024-07-04T12:32:31.953535Z", - "start_time": "2024-07-04T12:32:31.504431Z" - } - }, - "cell_type": "code", - "source": [ - "normalized_volumes = resize_sitk_volume(volumes)" - ], - "id": "185cb662e1b4c9cd", - "outputs": [], - "execution_count": 11 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2024-07-04T12:32:32.308226Z", - "start_time": "2024-07-04T12:32:31.958020Z" - } - }, - "cell_type": "code", - "source": [ - "array = sitk.GetArrayFromImage(normalized_volumes[1])\n", - "plt.imshow(array[40, :, :])\n", - "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" - ], - "id": "96a75fa203718430", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slice value range: 0 - 4\n" - ] - }, - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "execution_count": 12 - }, - { - "metadata": { - "ExecuteTime": { - "end_time": "2024-07-04T12:32:45.856184Z", - "start_time": "2024-07-04T12:32:45.597942Z" - } - }, - "cell_type": "code", - "source": [ - "array = sitk.GetArrayFromImage(volumes[0])\n", - "plt.imshow(array[40, :, :])\n", - "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" - ], - "id": "2f8c0a05160a98e3", - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Slice value range: 0 - 4\n" - ] - }, - { - "data": { - "text/plain": [ - "
" - ], - "image/png": "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m2++malTpwLg9Xqx2+2kpaWFlb3ynHq93k7P+aV9w9Err7zCjh07WLly5VX75Jz2zPHjx3nhhRcYP348b775Jo8//jhf/epXeemll4DL5+Va33+v10tOTk7YfqvVSkZGRlTndVBOOyZ6prS0lL179/LBBx/EuyqD2unTp/na177G+vXrSUhIiHd1hgzTNJk7dy4//OEPAZg1axZ79+5l1apVLF26tF/rMqhajFlZWVgslqt696qqqvB4PHGq1eCwbNky1q5dyzvvvMPIkSND2z0eD+3t7TQ0NISVv/KcejyeTs/5pX3DTXl5OdXV1cyePRur1YrVamXjxo0899xzWK1W3G63nNMeyM3NpaioKGzb5MmTqaioAC6fl2t9/z0eD9XV1WH7A4EAdXV1UZ3XQRWMdrudOXPmsGHDhtA20zTZsGEDxcXFcazZwKWUYtmyZbz22mu8/fbbFBYWhu2fM2cONpst7JweOnSIioqK0DktLi5mz549YX9w69evx+l0XvWHPBzcdttt7Nmzh127doVec+fOZcmSJaH/lnMavZtvvvmqoWSHDx9m1KhRABQWFuLxeMLOq8/nY8uWLWHntaGhgfLy8lCZt99+G9M0mTdvXvcrE33fUXy98soryuFwqNWrV6v9+/erxx57TKWlpYX17onLHn/8ceVyudS7776rKisrQ6/W1tZQmS9/+cuqoKBAvf3222r79u2quLhYFRcXh/ZfGlpy++23q127dql169ap7OzsYT20JNKVvdJKyTntia1btyqr1ap+8IMfqCNHjqiXX35ZJSUlqd/85jehMj/60Y9UWlqaev3119VHH32k7rnnnk6H68yaNUtt2bJFffDBB2r8+PFDf7iOUkr99Kc/VQUFBcput6sbb7xRbd68Od5VGrAIrqN41evFF18Mlblw4YL6h3/4B5Wenq6SkpLUfffdpyorK8OOc/LkSXXnnXeqxMRElZWVpb7+9a+rjo6Ofv40A1dkMMo57Zk//elPaurUqcrhcKhJkyapX/ziF2H7TdNU3/3ud5Xb7VYOh0Pddttt6tChQ2Flamtr1YMPPqhSUlKU0+lUX/ziF1VTU1NU9ZD5GIUQIsKguscohBD9QYJRCCEiSDAKIUQECUYhhIggwSiEEBEkGIUQIoIEoxBCRJBgFEKICBKMQggRQYJRCCEiSDAKIUSE/x+1kQUOTRWVgQAAAABJRU5ErkJggg==" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "execution_count": 14 - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 2 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/normalized_volumes.ipynb b/notebooks/normalized_volumes.ipynb new file mode 100644 index 0000000..2400136 --- /dev/null +++ b/notebooks/normalized_volumes.ipynb @@ -0,0 +1,88 @@ +{ + "cells": [ + { + "metadata": {}, + "cell_type": "code", + "source": [ + "%load_ext autoreload\n", + "%autoreload 2\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "import SimpleITK as sitk\n", + "from armscan_env import config\n", + "from armscan_env.volumes.loading import load_sitk_volumes, resize_sitk_volume\n", + "\n", + "config = config.get_config()" + ], + "id": "ecaf94d658c47deb", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "volumes = load_sitk_volumes(normalize=False)" + ], + "id": "a468f5f6f4c63d26", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "normalized_volumes = resize_sitk_volume(volumes)" + ], + "id": "185cb662e1b4c9cd", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "array = sitk.GetArrayFromImage(normalized_volumes[1])\n", + "plt.imshow(array[40, :, :])\n", + "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" + ], + "id": "96a75fa203718430", + "outputs": [], + "execution_count": null + }, + { + "metadata": {}, + "cell_type": "code", + "source": [ + "array = sitk.GetArrayFromImage(volumes[0])\n", + "plt.imshow(array[40, :, :])\n", + "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" + ], + "id": "2f8c0a05160a98e3", + "outputs": [], + "execution_count": null + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 46b182c5827549bf059dea25751ceb982c94c8cf Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 4 Jul 2024 16:07:28 +0200 Subject: [PATCH 26/36] fixup! loading volumes and standardizing volumes spacing --- docs/02_notebooks/L4_environment.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index bd50169..c93d9b7 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -185,7 +185,7 @@ "volume_size = volumes[0].GetSize()\n", "\n", "env = LabelmapEnv(\n", - " name2volume={\"2\": volumes[1]},\n", + " name2volume={\"1\": volumes[1], \"2\": volumes[1]},\n", " observation=LabelmapSliceAsChannelsObservation(\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " action_shape=(4,),\n", From fb067179c5e28ee9c7be0ccdeac9f896be820ac2 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 4 Jul 2024 16:45:20 +0200 Subject: [PATCH 27/36] test optimal action --- pyproject.toml | 6 ++-- src/armscan_env/envs/labelmaps_navigation.py | 4 +-- test/armscan_env/test_labelmap_volumes.py | 33 ++++++++++++++------ 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1aa6a10..e9868ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,10 +145,10 @@ test = "pytest test --cov=armscan_env --cov-report=xml --cov-report=term-missing # Adjust to a smaller set of tests if appropriate test-subset = "pytest test --color=yes" _black_check = "black --check src scripts docs notebooks" -_ruff_check = "ruff check src scripts docs notebooks" +_ruff_check = "ruff check src scripts docs notebooks test" _ruff_check_nb = "nbqa ruff docs notebooks" -_black_format = "black src scripts docs notebooks" -_ruff_format = "ruff --fix src scripts docs notebooks" +_black_format = "black src scripts docs notebooks test" +_ruff_format = "ruff --fix src scripts docs notebooks test" _ruff_format_nb = "nbqa ruff --fix docs notebooks" lint = ["_black_check", "_ruff_check", "_ruff_check_nb"] _poetry_install_sort_plugin = "poetry self add poetry-plugin-sort" diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 95758af..375692a 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -34,7 +34,7 @@ log = logging.getLogger(__name__) -_VOL_NAME_TO_OPTIMAL_ACTION = { +VOL_NAME_TO_OPTIMAL_ACTION = { "1": ManipulatorAction(rotation=(19.3, 0.0), translation=(0.0, 140.0)), "2": ManipulatorAction(rotation=(5, 0), translation=(0, 112)), } @@ -308,7 +308,7 @@ def sample_initial_state(self) -> LabelmapStateAction: ) sampled_image_name = np.random.choice(list(self.name2volume.keys())) self._cur_labelmap_name = sampled_image_name - volume_optimal_action = deepcopy(_VOL_NAME_TO_OPTIMAL_ACTION[sampled_image_name]) + volume_optimal_action = deepcopy(VOL_NAME_TO_OPTIMAL_ACTION[sampled_image_name]) if self._apply_volume_transformation: volume_transformation_action = ManipulatorAction.sample() diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index 90db539..6dc4786 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -1,11 +1,12 @@ -import os - import numpy as np import pytest import SimpleITK as sitk -from armscan_env.clustering import TissueLabel +from armscan_env.clustering import TissueClusters, TissueLabel from armscan_env.config import get_config +from armscan_env.envs.labelmaps_navigation import VOL_NAME_TO_OPTIMAL_ACTION +from armscan_env.envs.rewards import anatomy_based_rwd from armscan_env.envs.state_action import ManipulatorAction +from armscan_env.volumes.loading import load_sitk_volumes from armscan_env.volumes.slicing import get_volume_slice config = get_config() @@ -13,10 +14,7 @@ @pytest.fixture(scope="session") def labelmaps(): - result = [ - (sitk.ReadImage(config.get_labels_path(i), i), i) - for i in range(1, len(os.listdir(config.get_labels_basedir()))) - ] + result = load_sitk_volumes(normalize=False) if not result: raise ValueError("No labelmaps files found in the labels directory") return result @@ -25,19 +23,19 @@ def labelmaps(): class TestLabelMaps: @staticmethod def test_no_empty_labelmaps(labelmaps): - for labelmap, _i in labelmaps: + for labelmap in labelmaps: assert labelmap.GetSize() != (0, 0, 0) @staticmethod def test_all_tissue_labels_present(labelmaps): - for labelmap, _i in labelmaps: + for labelmap in labelmaps: img_array = sitk.GetArrayFromImage(labelmap) for label in TissueLabel: assert np.any(img_array == label.value) @staticmethod def test_labelmap_properly_sliced(labelmaps): - for labelmap, _i in labelmaps: + for labelmap in labelmaps: slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) sliced_volume = get_volume_slice( volume=labelmap, @@ -49,3 +47,18 @@ def test_labelmap_properly_sliced(labelmaps): ) sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] assert not np.all(sliced_img == 0) + + @staticmethod + def test_optimal_actions(labelmaps): + for i, labelmap in enumerate(labelmaps): + optimal_action = VOL_NAME_TO_OPTIMAL_ACTION[str(i + 1)] + slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) + sliced_volume = get_volume_slice( + volume=labelmap, + slice_shape=slice_shape, + action=optimal_action, + ) + sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] + cluster = TissueClusters.from_labelmap_slice(sliced_img.T) + reward = anatomy_based_rwd(cluster) + assert reward < 0.1 From ed1f525ea4ed77da6a87585e508e5adc3789b2bc Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 12 Jul 2024 13:19:10 +0200 Subject: [PATCH 28/36] Changed slicing to use standard sitk transform nbstripout for all notebooks --- .pre-commit-config.yaml | 2 +- docs/02_notebooks/L4_environment.ipynb | 27 +- docs/02_notebooks/L5_linear_sweep.ipynb | 2 +- docs/nbstripout.py | 10 - nbstripout.py | 13 + notebooks/normalized_volumes.ipynb | 50 +-- notebooks/random_volume_transformations.ipynb | 338 ++++++++++++++---- pyproject.toml | 2 +- src/armscan_env/envs/labelmaps_navigation.py | 16 +- src/armscan_env/envs/state_action.py | 6 +- src/armscan_env/volumes/slicing.py | 204 +++++------ src/armscan_env/wrapper.py | 12 +- test/armscan_env/test_labelmap_volumes.py | 34 +- 13 files changed, 451 insertions(+), 265 deletions(-) delete mode 100644 docs/nbstripout.py create mode 100644 nbstripout.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 46e5fc4..16ad284 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -52,5 +52,5 @@ repos: language: system - id: clean-nbs name: clean-nbs - entry: poetry run python docs/nbstripout.py + entry: poetry run python nbstripout.py language: system \ No newline at end of file diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index c93d9b7..f18f527 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -36,11 +36,18 @@ "import SimpleITK as sitk\n", "from armscan_env.clustering import TissueClusters\n", "from armscan_env.config import get_config\n", + "from armscan_env.envs.labelmaps_navigation import (\n", + " LabelmapClusteringBasedReward,\n", + " LabelmapEnv,\n", + " LabelmapEnvTerminationCriterion,\n", + ")\n", + "from armscan_env.envs.observations import LabelmapSliceAsChannelsObservation\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", "from armscan_env.volumes.loading import load_sitk_volumes\n", "from armscan_env.volumes.slicing import get_volume_slice\n", + "from celluloid import Camera\n", "from IPython.core.display import HTML\n", "\n", "config = get_config()" @@ -74,8 +81,6 @@ }, "outputs": [], "source": [ - "from celluloid import Camera\n", - "\n", "t = [160, 155, 150, 148, 146, 142, 140, 140, 115, 120, 125, 125, 130, 130, 135, 138, 140, 140, 140]\n", "z = [0, -5, 0, 0, 5, 15, 19.3, -10, 0, 0, 0, 5, -8, 8, 0, -10, -10, 10, 19.3]\n", "o = volumes[0].GetOrigin()\n", @@ -113,7 +118,7 @@ " slice_shape=slice_shape,\n", " action=ManipulatorAction(rotation=(z[i], 0.0), translation=(0.0, t[i])),\n", " )\n", - " sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :].T\n", + " sliced_img = sitk.GetArrayFromImage(sliced_volume).T\n", " ax2.imshow(sliced_img.T, aspect=6, origin=\"lower\")\n", " ax2.set_title(f\"Slice {i}\")\n", "\n", @@ -175,17 +180,10 @@ "metadata": {}, "outputs": [], "source": [ - "from armscan_env.envs.labelmaps_navigation import (\n", - " LabelmapClusteringBasedReward,\n", - " LabelmapEnv,\n", - " LabelmapEnvTerminationCriterion,\n", - ")\n", - "from armscan_env.envs.observations import LabelmapSliceAsChannelsObservation\n", - "\n", "volume_size = volumes[0].GetSize()\n", "\n", "env = LabelmapEnv(\n", - " name2volume={\"1\": volumes[1], \"2\": volumes[1]},\n", + " name2volume={\"1\": volumes[0], \"2\": volumes[1]},\n", " observation=LabelmapSliceAsChannelsObservation(\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " action_shape=(4,),\n", @@ -224,13 +222,6 @@ "source": [ "HTML(animation.to_jshtml())" ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index 956e2fe..177984a 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -232,7 +232,7 @@ "source": [ "print(\n", " \"Observed 'rewards': \\n\",\n", - " [round(obs[1][-1], 4) for obs in projected_env_rollout.observations],\n", + " [round(obs[-1], 4) for obs in projected_env_rollout.observations],\n", ")\n", "print(\"Env rewards: \\n\", [round(r, 4) for r in projected_env_rollout.rewards])" ] diff --git a/docs/nbstripout.py b/docs/nbstripout.py deleted file mode 100644 index 95d4193..0000000 --- a/docs/nbstripout.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Implements a platform-independent way of calling nbstripout (used in pyproject.toml).""" -import glob -import os -from pathlib import Path - -if __name__ == "__main__": - docs_dir = Path(__file__).parent - for path in glob.glob(str(docs_dir / "02_notebooks" / "*.ipynb")): - cmd = f"nbstripout {path}" - os.system(cmd) diff --git a/nbstripout.py b/nbstripout.py new file mode 100644 index 0000000..989d9f7 --- /dev/null +++ b/nbstripout.py @@ -0,0 +1,13 @@ +"""Implements a platform-independent way of calling nbstripout (used in pyproject.toml).""" +import glob +import os +from pathlib import Path + +if __name__ == "__main__": + main_dir = Path(__file__).parent + for path in glob.glob(str(main_dir / "docs" / "02_notebooks" / "*.ipynb")): + cmd = f"nbstripout {path}" + os.system(cmd) + for path in glob.glob(str(main_dir / "notebooks" / "*.ipynb")): + cmd = f"nbstripout {path}" + os.system(cmd) diff --git a/notebooks/normalized_volumes.ipynb b/notebooks/normalized_volumes.ipynb index 2400136..456c24a 100644 --- a/notebooks/normalized_volumes.ipynb +++ b/notebooks/normalized_volumes.ipynb @@ -1,8 +1,11 @@ { "cells": [ { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "ecaf94d658c47deb", + "metadata": {}, + "outputs": [], "source": [ "%load_ext autoreload\n", "%autoreload 2\n", @@ -14,54 +17,51 @@ "from armscan_env.volumes.loading import load_sitk_volumes, resize_sitk_volume\n", "\n", "config = config.get_config()" - ], - "id": "ecaf94d658c47deb", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", - "source": [ - "volumes = load_sitk_volumes(normalize=False)" - ], + "execution_count": null, "id": "a468f5f6f4c63d26", + "metadata": {}, "outputs": [], - "execution_count": null + "source": [ + "volumes = load_sitk_volumes(normalize=False)" + ] }, { - "metadata": {}, "cell_type": "code", - "source": [ - "normalized_volumes = resize_sitk_volume(volumes)" - ], + "execution_count": null, "id": "185cb662e1b4c9cd", + "metadata": {}, "outputs": [], - "execution_count": null + "source": [ + "normalized_volumes = resize_sitk_volume(volumes)" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "96a75fa203718430", + "metadata": {}, + "outputs": [], "source": [ "array = sitk.GetArrayFromImage(normalized_volumes[1])\n", "plt.imshow(array[40, :, :])\n", "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" - ], - "id": "96a75fa203718430", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "2f8c0a05160a98e3", + "metadata": {}, + "outputs": [], "source": [ "array = sitk.GetArrayFromImage(volumes[0])\n", "plt.imshow(array[40, :, :])\n", "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" - ], - "id": "2f8c0a05160a98e3", - "outputs": [], - "execution_count": null + ] } ], "metadata": { diff --git a/notebooks/random_volume_transformations.ipynb b/notebooks/random_volume_transformations.ipynb index 557b9a8..b369b1d 100644 --- a/notebooks/random_volume_transformations.ipynb +++ b/notebooks/random_volume_transformations.ipynb @@ -1,173 +1,387 @@ { "cells": [ { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "60c69d9345beb9d0", + "metadata": {}, + "outputs": [], "source": [ "%load_ext autoreload\n", "%autoreload 2" - ], - "id": "60c69d9345beb9d0", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "bf5c60e86d1e8e19", + "metadata": {}, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "import numpy as np\n", "import SimpleITK as sitk\n", "from armscan_env import config\n", "from armscan_env.clustering import TissueClusters\n", + "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", "from armscan_env.volumes.slicing import (\n", - " EulerTransform,\n", " create_transformed_volume,\n", " get_volume_slice,\n", ")\n", "\n", "config = config.get_config()" - ], - "id": "bf5c60e86d1e8e19", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "58c3c50872ffca22", + "metadata": {}, + "outputs": [], "source": [ "volume = sitk.ReadImage(config.get_labels_path(1))\n", "volume_img = sitk.GetArrayFromImage(volume)\n", - "plt.imshow(volume_img[40, :, :])\n", + "\n", + "x_size, y_size, z_size = (\n", + " sz * sp for sz, sp in zip(volume.GetSize(), volume.GetSpacing(), strict=True)\n", + ")\n", + "extent_xy = (0, x_size, y_size, 0)\n", + "\n", + "plt.imshow(volume_img[40, :, :], extent=extent_xy)\n", "action = ManipulatorAction(rotation=(19, 0), translation=(0, 140))\n", "\n", "o = volume.GetOrigin()\n", - "x_dash = np.arange(volume_img.shape[2])\n", - "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + action.translation[1], o[2]])[1]\n", + "x_dash = np.arange(x_size)\n", + "b = action.translation[1]\n", "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", "plt.show()" - ], - "id": "ae347cf3897968a6", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "29c544e76d3f6144", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ "sliced_volume = get_volume_slice(\n", " action=action,\n", " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", - "sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", + "sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", "\n", - "slice = sliced_img\n", - "plt.imshow(slice, aspect=6)\n", + "extent_xz = (0, x_size, z_size, 0)\n", + "plt.imshow(sliced_img, extent=extent_xz)\n", "plt.show()" - ], - "id": "cb9c333a74781d5a", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6803472d7529d369", + "metadata": { + "jupyter": { + "is_executing": true + } + }, "outputs": [], - "execution_count": null + "source": [ + "transform = sitk.Euler3DTransform()\n", + "transform.SetRotation(0, 0, np.deg2rad(19))\n", + "transform.SetTranslation((0, 10, 0))\n", + "transform.SetCenter(volume.GetOrigin())\n", + "resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID())\n", + "plt.imshow(sitk.GetArrayFromImage(resampled)[40, :, :], extent=extent_xy)" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "677890fe4c481d25", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ - "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(15, 15))\n", - "transformed_volume = create_transformed_volume(volume, volume_transformation)\n", - "transformed_action = EulerTransform(volume_transformation).transform_action(action)" - ], + "transformation_action = ManipulatorAction(rotation=(19, 0), translation=(0, 140))\n", + "relative_action = ManipulatorAction(rotation=(0, 0), translation=(0, 0))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "638f0b403ea85e68", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], + "source": [ + "volume_rotation = np.deg2rad(transformation_action.rotation)\n", + "volume_translation = transformation_action.translation\n", + "\n", + "volume_transform = sitk.Euler3DTransform()\n", + "volume_transform.SetRotation(volume_rotation[1], 0, volume_rotation[0])\n", + "volume_transform.SetTranslation((*volume_translation, 0))\n", + "\n", + "inverse_volume_transform = volume_transform.GetInverse()\n", + "inverse_volume_transform_matrix = np.eye(4)\n", + "inverse_volume_transform_matrix[:3, :3] = np.array(inverse_volume_transform.GetMatrix()).reshape(\n", + " 3,\n", + " 3,\n", + ")\n", + "inverse_volume_transform_matrix[:3, 3] = inverse_volume_transform.GetTranslation()\n", + "\n", + "action_rotation = np.deg2rad(relative_action.rotation)\n", + "action_translation = relative_action.translation\n", + "action_transform = sitk.Euler3DTransform()\n", + "action_transform.SetRotation(action_rotation[1], 0, action_rotation[0])\n", + "action_transform.SetTranslation((*action_translation, 0))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5ecf961f33977811", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], + "source": [ + "composite = sitk.CompositeTransform(3)\n", + "composite.AddTransform(inverse_volume_transform)\n", + "composite.AddTransform(action_transform)" + ] + }, + { + "cell_type": "code", + "execution_count": null, "id": "26ffcc6d7dece611", + "metadata": { + "jupyter": { + "is_executing": true + } + }, "outputs": [], - "execution_count": null + "source": [ + "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(-9.74, -4.31))\n", + "transformed_volume = create_transformed_volume(volume, volume_transformation)\n", + "transformed_action = transformed_volume.transform_action(action)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a54ca3679f89423e", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], + "source": [ + "print(f\"{action=}\\n{transformed_action=}\\n\")" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "db20a3ff9556e8b4", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ "transformed_img = sitk.GetArrayFromImage(transformed_volume)\n", - "plt.imshow(transformed_img[40, :, :])\n", + "\n", + "plt.imshow(transformed_img[40, :, :], extent=extent_xy)\n", "\n", "ot = transformed_volume.GetOrigin()\n", - "x_dash = np.arange(transformed_img.shape[2])\n", - "b = volume.TransformPhysicalPointToIndex([o[0], o[1] + transformed_action.translation[1], o[2]])[1]\n", + "x_dash = np.arange(x_size)\n", + "b = transformed_action.translation[1]\n", "y_dash = x_dash * np.tan(np.deg2rad(transformed_action.rotation[0])) + b\n", "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", "plt.show()" - ], - "id": "db20a3ff9556e8b4", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "acda09e94c3f2f2b", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ "sliced_transformed_volume = get_volume_slice(\n", " action=transformed_action,\n", " volume=transformed_volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", - "sliced_img = sitk.GetArrayFromImage(sliced_transformed_volume)[:, 0, :]\n", - "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", + "sliced_transformed_img = sitk.GetArrayFromImage(sliced_transformed_volume)\n", + "print(f\"Slice value range: {np.min(sliced_transformed_img)} - {np.max(sliced_transformed_img)}\")\n", "\n", - "slice = sliced_img\n", - "plt.imshow(slice, aspect=6)\n", + "plt.imshow(sliced_transformed_img, extent=extent_xz)\n", "plt.show()" - ], - "id": "acda09e94c3f2f2b", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7daab6fa59e5c75", + "metadata": { + "jupyter": { + "is_executing": true + } + }, "outputs": [], - "execution_count": null + "source": [ + "cluster = TissueClusters.from_labelmap_slice(sliced_transformed_img.T)\n", + "show_clusters(cluster, sliced_transformed_img.T)\n", + "reward = anatomy_based_rwd(cluster)\n", + "print(f\"Reward: {reward}\")\n", + "plt.show()" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "fb6cfecff1cb7cd4", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", "volume_2_img = sitk.GetArrayFromImage(volume_2)\n", + "x_size_2, y_size_2, z_size_2 = (\n", + " sz * sp for sz, sp in zip(volume_2.GetSize(), volume_2.GetSpacing(), strict=True)\n", + ")\n", + "extent_xy_2 = (0, x_size_2, y_size_2, 0)\n", + "\n", "spacing = volume_2.GetSpacing()\n", - "plt.imshow(volume_2_img[51, :, :])\n", + "plt.imshow(volume_2_img[51, :, :], extent=extent_xy_2)\n", "action_2 = ManipulatorAction(rotation=(5, 0), translation=(0, 112))\n", "\n", "o = volume_2.GetOrigin()\n", - "x_dash = np.arange(volume_2_img.shape[2])\n", - "b = volume_2.TransformPhysicalPointToIndex([o[0], o[1] + action_2.translation[1], o[2]])[1]\n", + "x_dash = np.arange(x_size_2)\n", + "b = action_2.translation[1]\n", "y_dash = x_dash * np.tan(np.deg2rad(action_2.rotation[0])) + b\n", "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", "plt.show()" - ], - "id": "fb6cfecff1cb7cd4", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "6462b823c7903838", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], "source": [ "sliced_volume_2 = get_volume_slice(\n", " action=action_2,\n", " volume=volume_2,\n", " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", ")\n", - "sliced_img_2 = sitk.GetArrayFromImage(sliced_volume_2)[:, 0, :]\n", - "np.save(\"./array\", sliced_img_2)\n", + "sliced_img_2 = sitk.GetArrayFromImage(sliced_volume_2)\n", "\n", "cluster = TissueClusters.from_labelmap_slice(sliced_img_2.T)\n", "show_clusters(cluster, sliced_img_2.T, aspect=spacing[2] / spacing[0])\n", "\n", "plt.show()" - ], - "id": "6462b823c7903838", + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "38dc246ec22f0835", + "metadata": { + "jupyter": { + "is_executing": true + } + }, "outputs": [], - "execution_count": null + "source": [ + "volume_transformation_2 = ManipulatorAction(rotation=(10, 0), translation=(-9.74, -4.31))\n", + "transformed_volume_2 = create_transformed_volume(volume_2, volume_transformation_2)\n", + "transformed_action_2 = transformed_volume_2.transform_action(action_2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5504f6f5d6ad9f5", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], + "source": [ + "transformed_img_2 = sitk.GetArrayFromImage(transformed_volume_2)\n", + "\n", + "plt.imshow(transformed_img_2[51, :, :], extent=extent_xy_2)\n", + "\n", + "x_dash = np.arange(x_size_2)\n", + "b = transformed_action_2.translation[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(transformed_action_2.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a47159b4af236854", + "metadata": { + "jupyter": { + "is_executing": true + } + }, + "outputs": [], + "source": [ + "sliced_transformed_volume_2 = get_volume_slice(\n", + " action=transformed_action_2,\n", + " volume=transformed_volume_2,\n", + " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", + ")\n", + "sliced_transformed_img_2 = sitk.GetArrayFromImage(sliced_transformed_volume_2)\n", + "\n", + "cluster = TissueClusters.from_labelmap_slice(sliced_transformed_img_2.T)\n", + "show_clusters(cluster, sliced_transformed_img_2.T, aspect=spacing[2] / spacing[0])\n", + "reward = anatomy_based_rwd(cluster)\n", + "print(f\"Reward: {reward}\")\n", + "\n", + "plt.show()" + ] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index e9868ce..534389d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,7 +153,7 @@ _ruff_format_nb = "nbqa ruff --fix docs notebooks" lint = ["_black_check", "_ruff_check", "_ruff_check_nb"] _poetry_install_sort_plugin = "poetry self add poetry-plugin-sort" _poetry_sort = "poetry sort" -clean-nbs = "python docs/nbstripout.py" +clean-nbs = "python nbstripout.py" format = ["_black_format", "_ruff_format", "_ruff_format_nb", "_poetry_install_sort_plugin", "_poetry_sort"] _autogen_rst = "python docs/autogen_rst.py" _sphinx_build = "sphinx-build -W -b html docs docs/_build" diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 375692a..0ea5387 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -17,7 +17,6 @@ from armscan_env.envs.state_action import LabelmapStateAction, ManipulatorAction from armscan_env.util.visualizations import show_clusters from armscan_env.volumes.slicing import ( - EulerTransform, create_transformed_volume, get_volume_slice, ) @@ -251,7 +250,7 @@ def _get_slice_from_action(self, action: np.ndarray | ManipulatorAction) -> np.n slice_shape=self._slice_shape, action=manipulator_action, ) - return sitk.GetArrayFromImage(sliced_volume)[:, 0, :].T + return sitk.GetArrayFromImage(sliced_volume).T def _get_initial_slice(self) -> np.ndarray: action_to_initial_slice = self._get_action_leading_to_initial_state() @@ -285,7 +284,11 @@ def apply_volume_transformation( :param optimal_action: the optimal action for the volume to transform accordingly :return: the transformed volume and the transformed optimal action """ - transformed_optimal_action = EulerTransform(volume_transformation_action).transform_action( + transformed_volume = create_transformed_volume( + volume=volume, + transformation_action=volume_transformation_action, + ) + transformed_optimal_action = transformed_volume.transform_action( optimal_action, ) if self.rotation_bounds: @@ -294,10 +297,7 @@ def apply_volume_transformation( bounds[1] += abs(volume_transformation_action.rotation[1]) self.rotation_bounds = tuple(bounds) # type: ignore return ( - create_transformed_volume( - volume=volume, - transformation_action=volume_transformation_action, - ), + transformed_volume, transformed_optimal_action, ) @@ -331,7 +331,7 @@ def sample_initial_state(self) -> LabelmapStateAction: ), labels_2d_slice=initial_slice, # TODO: pass the env's optimal position and labelmap or remove them from the StateAction? - optimal_position=None, + optimal_position=self._cur_optimal_action, optimal_labelmap=None, ) diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index 60c6bd5..e62afc2 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -111,8 +111,8 @@ def project_to_positive(self) -> None: @classmethod def sample( cls, - rotation_range: tuple[float, float] = (20.0, 5.0), - translation_range: tuple[float, float] = (5.0, 5.0), + rotation_range: tuple[float, float] = (10.0, 0.0), + translation_range: tuple[float, float] = (10.0, 10.0), ) -> Self: rotation = ( np.random.uniform(-rotation_range[0], rotation_range[0]), @@ -132,7 +132,7 @@ class LabelmapStateAction(StateAction): labels_2d_slice: np.ndarray """Two-dimensional slice of the labelmap, i.e., an array of shape (N, M) with integer values. Each integer represents a different label (bone, nerve, etc.)""" - optimal_position: np.ndarray | None = None + optimal_position: np.ndarray | ManipulatorAction | None = None """The optimal position for the 2D slice, i.e., the position where the slice is the most informative. May be None if the optimal position is not known.""" optimal_labelmap: np.ndarray | None = None diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py index f363f11..ccdff51 100644 --- a/src/armscan_env/volumes/slicing.py +++ b/src/armscan_env/volumes/slicing.py @@ -8,45 +8,6 @@ log = logging.getLogger(__name__) -def padding(original_array: np.ndarray) -> np.ndarray: - """Pad an array to make it square. - - :param original_array: array to pad - :return: padded array. - """ - # Find the maximum dimension - max_dim = max(original_array.shape) - - # Calculate padding for each dimension (left and right) - padding_x_left = (max_dim - original_array.shape[0]) // 2 - padding_x_right = max_dim - original_array.shape[0] - padding_x_left - - padding_y_left = (max_dim - original_array.shape[1]) // 2 - padding_y_right = max_dim - original_array.shape[1] - padding_y_left - - padding_z_left = (max_dim - original_array.shape[2]) // 2 - padding_z_right = max_dim - original_array.shape[2] - padding_z_left - - # Pad the array with zeros - padded_array = np.pad( - original_array, - ( - (padding_x_left, padding_x_right), - (padding_y_left, padding_y_right), - (padding_z_left, padding_z_right), - ), - mode="constant", - ) - - # Verify the shapes - log.debug( - f"Original Array Shape: {original_array.shape}\n" - f"Padded Array Shape: {padded_array.shape}", - ) - - return padded_array - - class EulerTransform: def __init__(self, action: ManipulatorAction, origin: np.ndarray | None = None): if origin is None: @@ -93,33 +54,6 @@ def get_angles_from_rotation_matrix(rotation_matrix: np.ndarray) -> np.ndarray: return np.array([th_z1, th_x2]) - def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: - """Transform an action to be relative to the new coordinate system.""" - volume_transform_matrix = self.get_transform_matrix() - - action_matrix = EulerTransform(relative_action).get_transform_matrix() - new_action_matrix = np.dot( - np.linalg.inv(volume_transform_matrix), - action_matrix, - ) # 1_A_s = 1_T_0 * 0_A_s - - # new_action_translation = action_matrix[:2, 3] - volume_transform_matrix[:2, 3] - new_action_rotation = self.get_angles_from_rotation_matrix(new_action_matrix[:3, :3]) - new_action_translation = new_action_matrix[:2, 3] - - transformed_action = ManipulatorAction( - rotation=new_action_rotation, - translation=new_action_translation, - ) - - log.debug( - f"Random transformation: {self.action}\n" - f"Original action: {relative_action}\n" - f"Transformed action: {transformed_action}\n", - ) - - return transformed_action - class TransformedVolume(sitk.Image): """Represents a volume that has been transformed by an action. @@ -127,18 +61,72 @@ class TransformedVolume(sitk.Image): Should only ever be instantiated by `create_transformed_volume`. """ - def __init__(self, *args: Any, transformation_action: ManipulatorAction, _private: int): + def __init__(self, *args: Any, transformation_action: ManipulatorAction | None, _private: int): if _private != 42: raise ValueError( "TransformedVolume should only be instantiated by create_transformed_volume.", ) + if transformation_action is None: + transformation_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) super().__init__(*args) self._transformation_action = transformation_action @property - def transformation_action(self) -> ManipulatorAction | None: + def transformation_action(self) -> ManipulatorAction: return self._transformation_action + def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: + """Transform an action by the inverse of the volume transformation to be relative to the new coordinate + system. + """ + origin = np.array(self.GetOrigin()) + + volume_rotation = np.deg2rad(self.transformation_action.rotation) + volume_translation = self.transformation_action.translation + volume_transform = sitk.Euler3DTransform( + origin, + volume_rotation[1], + 0, + volume_rotation[0], + (*volume_translation, 0), + ) + + inverse_volume_transform = volume_transform.GetInverse() + inverse_volume_transform_matrix = np.eye(4) + inverse_volume_transform_matrix[:3, :3] = np.array( + inverse_volume_transform.GetMatrix(), + ).reshape(3, 3) + inverse_volume_transform_matrix[:3, 3] = inverse_volume_transform.GetTranslation() + + action_rotation = np.deg2rad(relative_action.rotation) + action_translation = relative_action.translation + action_transform = sitk.Euler3DTransform( + origin, + action_rotation[1], + 0, + action_rotation[0], + (*action_translation, 0), + ) + + action_transform_matrix = np.eye(4) + action_transform_matrix[:3, :3] = np.array(action_transform.GetMatrix()).reshape(3, 3) + action_transform_matrix[:3, 3] = action_transform.GetTranslation() + + # 1_A_s = 1_T_0 * 0_A_s + new_action_matrix = np.dot(inverse_volume_transform_matrix, action_transform_matrix) + transformed_action = ManipulatorAction( + rotation=EulerTransform.get_angles_from_rotation_matrix(new_action_matrix[:3, :3]), + translation=new_action_matrix[:2, 3], + ) + + log.debug( + f"Random transformation: {self.transformation_action}\n" + f"Original action: {relative_action}\n" + f"Transformed action: {transformed_action}\n", + ) + + return transformed_action + def create_transformed_volume( volume: sitk.Image, @@ -157,30 +145,17 @@ def create_transformed_volume( ) origin = np.array(volume.GetOrigin()) - transform_matrix = EulerTransform(transformation_action, origin).get_transform_matrix() - - # Define plane's coordinate system - e1 = transform_matrix[0][:3] - e2 = transform_matrix[1][:3] - e3 = transform_matrix[2][:3] - img_o = transform_matrix[:, -1:].flatten()[:3] # origin of the image plane - - direction = np.stack([e1, e2, e3], axis=0).flatten() - - resampler = sitk.ResampleImageFilter() - spacing = volume.GetSpacing() - - resampler.SetOutputDirection(direction.tolist()) - resampler.SetOutputOrigin(img_o.tolist()) - resampler.SetOutputSpacing(spacing) - resampler.SetSize(volume.GetSize()) - resampler.SetInterpolator(sitk.sitkNearestNeighbor) - - # Resample the volume on the arbitrary plane - transformed_volume = resampler.Execute(volume) + rotation = np.deg2rad(transformation_action.rotation) + translation = transformation_action.translation + + transform = sitk.Euler3DTransform() + transform.SetRotation(rotation[1], 0, rotation[0]) + transform.SetTranslation((*translation, 0)) + transform.SetCenter(origin) + resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID()) # needed to deal with rotation dependency of the volume return TransformedVolume( - transformed_volume, + resampled, transformation_action=transformation_action, _private=42, ) @@ -188,52 +163,33 @@ def create_transformed_volume( def get_volume_slice( volume: sitk.Image, - # TODO: shouldn't there be a default shape, like the native shape of the volume itself? - slice_shape: tuple[int, int], action: ManipulatorAction, + slice_shape: tuple[int, int] | None = None, ) -> sitk.Image: """Slice a 3D volume with arbitrary rotation and translation. :param volume: 3D volume to be sliced - :param slice_shape: shape of the output slice :param action: action to transform the volume + :param slice_shape: shape of the output slice :return: the sliced volume. """ - o = np.array(volume.GetOrigin()) + if slice_shape is None: + slice_shape = (volume.GetSize()[0], volume.GetSize()[2]) - if isinstance(volume, TransformedVolume): - # TODO: why is origin not used here? - volume_transformation = EulerTransform(volume.transformation_action).get_transform_matrix() - action_transformation = EulerTransform(action).get_transform_matrix() - # TODO: why not use action_transformation.transform_action ? - inverse_transformed_action = np.dot(volume_transformation, action_transformation) - action_rotation = EulerTransform.get_angles_from_rotation_matrix( - inverse_transformed_action[:3, :3], - ) - action_translation = (inverse_transformed_action[:3, 3] - volume_transformation[:3, 3])[:2] - action = ManipulatorAction(rotation=action_rotation, translation=(action_translation)) - - # TODO: seems to be recomputed as action_transformation in block above - can it be computed once instead? - euler_transform = EulerTransform(action, o) - eul_tr = euler_transform.get_transform_matrix() - - # Define plane's coordinate system - rotation = eul_tr[:3, :3] - translation = eul_tr[:, -1:].flatten()[:3] # origin of the image plane + origin = np.array(volume.GetOrigin()) + rotation = np.deg2rad(action.rotation) + translation = action.translation - rotation = rotation.flatten() + transform = sitk.Euler3DTransform() + transform.SetRotation(rotation[1], 0, rotation[0]) + transform.SetTranslation((*translation, 0)) + transform.SetCenter(origin) + resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID()) resampler = sitk.ResampleImageFilter() - spacing = volume.GetSpacing() - - w = slice_shape[0] - h = slice_shape[1] - - resampler.SetOutputDirection(rotation.tolist()) - resampler.SetOutputOrigin(translation.tolist()) - resampler.SetOutputSpacing(spacing) - resampler.SetSize((w, 3, h)) + resampler.SetReferenceImage(resampled) + resampler.SetSize((slice_shape[0], 2, slice_shape[1])) resampler.SetInterpolator(sitk.sitkNearestNeighbor) # Resample the volume on the arbitrary plane - return resampler.Execute(volume) + return resampler.Execute(resampled)[:, 0, :] diff --git a/src/armscan_env/wrapper.py b/src/armscan_env/wrapper.py index cdda124..2476fd0 100644 --- a/src/armscan_env/wrapper.py +++ b/src/armscan_env/wrapper.py @@ -54,6 +54,9 @@ def __init__(self, env: LabelmapEnv | Env): def reset(self, **kwargs: Any) -> tuple[ObsType, dict[str, Any]]: return self.env.reset(**kwargs) + def render(self, **kwargs: Any) -> Any: + return self.env.render(**kwargs) + def __getattr__(self, item: str) -> Any: return getattr(self.env, item) @@ -73,7 +76,7 @@ def action(self, action: ActType) -> np.ndarray: pass -class PatchedFrameStackObservation(Wrapper): +class PatchedFrameStackObservation(PatchedWrapper): def __init__( self, env: Env[ObsType, ActType], @@ -159,13 +162,6 @@ def reset(self, **kwargs: Any) -> tuple[ObsType, dict[str, Any]]: ) return updated_obs, info - def render(self, **kwargs: Any) -> Any: - return self.env.render(**kwargs) - - # Like in PatchedWrapper - def __getattr__(self, item: str) -> Any: - return getattr(self.env, item) - class PatchedFlattenObservation(PatchedWrapper): """Flattens the environment's observation space and each observation from ``reset`` and ``step`` functions. diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index 6dc4786..a1aa9b9 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -7,7 +7,7 @@ from armscan_env.envs.rewards import anatomy_based_rwd from armscan_env.envs.state_action import ManipulatorAction from armscan_env.volumes.loading import load_sitk_volumes -from armscan_env.volumes.slicing import get_volume_slice +from armscan_env.volumes.slicing import create_transformed_volume, get_volume_slice config = get_config() @@ -45,7 +45,7 @@ def test_labelmap_properly_sliced(labelmaps): translation=(0.0, -labelmap.GetOrigin()[1]), ), ) - sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] + sliced_img = sitk.GetArrayFromImage(sliced_volume) assert not np.all(sliced_img == 0) @staticmethod @@ -58,7 +58,33 @@ def test_optimal_actions(labelmaps): slice_shape=slice_shape, action=optimal_action, ) - sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :] + sliced_img = sitk.GetArrayFromImage(sliced_volume) cluster = TissueClusters.from_labelmap_slice(sliced_img.T) reward = anatomy_based_rwd(cluster) - assert reward < 0.1 + assert reward > -0.1 + + @staticmethod + def test_rand_transformations(labelmaps): + for i, labelmap in enumerate(labelmaps): + optimal_action = VOL_NAME_TO_OPTIMAL_ACTION[str(i + 1)] + slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) + j = 0 + while j < 10: + volume_transformation_action = ManipulatorAction.sample() + transformed_labelmap = create_transformed_volume( + volume=labelmap, + transformation_action=volume_transformation_action, + ) + transformed_optimal_action = transformed_labelmap.transform_action(optimal_action) + sliced_volume = get_volume_slice( + volume=transformed_labelmap, + slice_shape=slice_shape, + action=transformed_optimal_action, + ) + sliced_img = sitk.GetArrayFromImage(sliced_volume) + cluster = TissueClusters.from_labelmap_slice(sliced_img.T) + reward = anatomy_based_rwd(cluster) + j += 1 + assert ( + reward > -0.1 + ), f"Reward: {reward} for volume {i + 1} and transformation {volume_transformation_action}" From 9c390765c977ec54d06c0d93c01ab1be19f37069 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 12 Jul 2024 16:32:33 +0200 Subject: [PATCH 29/36] add 2 DoF --- docs/02_notebooks/L4_environment.ipynb | 59 +++++++++++++++++++- docs/02_notebooks/L5_linear_sweep.ipynb | 28 ++++------ src/armscan_env/envs/labelmaps_navigation.py | 6 +- src/armscan_env/wrapper.py | 2 +- 4 files changed, 73 insertions(+), 22 deletions(-) diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index f18f527..79cd615 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -41,7 +41,10 @@ " LabelmapEnv,\n", " LabelmapEnvTerminationCriterion,\n", ")\n", - "from armscan_env.envs.observations import LabelmapSliceAsChannelsObservation\n", + "from armscan_env.envs.observations import (\n", + " ActionRewardObservation,\n", + " LabelmapSliceAsChannelsObservation,\n", + ")\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", @@ -222,6 +225,60 @@ "source": [ "HTML(animation.to_jshtml())" ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "volume_size = volumes[0].GetSize()\n", + "\n", + "env = LabelmapEnv(\n", + " name2volume={\"1\": volumes[0]},\n", + " observation=ActionRewardObservation(action_shape=(2,)).to_array_observation(),\n", + " slice_shape=(volume_size[0], volume_size[2]),\n", + " reward_metric=LabelmapClusteringBasedReward(),\n", + " termination_criterion=LabelmapEnvTerminationCriterion(),\n", + " max_episode_len=10,\n", + " rotation_bounds=(30.0, 10.0),\n", + " translation_bounds=(None, None),\n", + " render_mode=\"animation\",\n", + " project_actions_to=\"zy\",\n", + " apply_volume_transformation=True,\n", + ")\n", + "\n", + "observation, info = env.reset()\n", + "for _ in range(50):\n", + " action = env.action_space.sample()\n", + " epsilon = 0.1\n", + " if np.random.rand() > epsilon:\n", + " observation, reward, terminated, truncated, info = env.step(action)\n", + " else:\n", + " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", + " env.render()\n", + "\n", + " if terminated or truncated:\n", + " observation, info = env.reset(reset_render=False)\n", + "animation = env.get_cur_animation()\n", + "env.close()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "HTML(animation.to_jshtml())" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index 177984a..f6d462d 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -31,6 +31,7 @@ "from armscan_env.envs.observations import (\n", " ActionRewardObservation,\n", ")\n", + "from armscan_env.volumes.loading import load_sitk_volumes\n", "from armscan_env.wrapper import ArmscanEnvFactory\n", "from tqdm import tqdm\n", "\n", @@ -132,10 +133,9 @@ "metadata": {}, "outputs": [], "source": [ - "volume_1 = sitk.ReadImage(config.get_labels_path(1))\n", - "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", - "img_array_1 = sitk.GetArrayFromImage(volume_1)\n", - "img_array_2 = sitk.GetArrayFromImage(volume_2)" + "volumes = load_sitk_volumes(normalize=True)\n", + "img_array_1 = sitk.GetArrayFromImage(volumes[0])\n", + "img_array_2 = sitk.GetArrayFromImage(volumes[1])" ] }, { @@ -145,10 +145,10 @@ "metadata": {}, "outputs": [], "source": [ - "volume_size = volume_1.GetSize()\n", + "volume_size = volumes[0].GetSize()\n", "\n", "env = ArmscanEnvFactory(\n", - " name2volume={\"1\": volume_1},\n", + " name2volume={\"1\": volumes[0]},\n", " observation=ActionRewardObservation(action_shape=(4,)).to_array_observation(),\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " reward_metric=LabelmapClusteringBasedReward(),\n", @@ -190,10 +190,10 @@ "metadata": {}, "outputs": [], "source": [ - "volume_size = volume_1.GetSize()\n", + "volume_size = volumes[0].GetSize()\n", "\n", "projected_env = ArmscanEnvFactory(\n", - " name2volume={\"2\": volume_2},\n", + " name2volume={\"2\": volumes[1]},\n", " observation=ActionRewardObservation(action_shape=(1,)).to_array_observation(),\n", " slice_shape=(volume_size[0], volume_size[2]),\n", " reward_metric=LabelmapClusteringBasedReward(),\n", @@ -246,19 +246,11 @@ "source": [ "projected_env.get_cur_animation_as_html()" ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4fb82c1487521b11", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -272,7 +264,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.4" + "version": "3.11.9" } }, "nbformat": 4, diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 0ea5387..9e6a9b6 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -79,7 +79,7 @@ def __init__( translation_bounds: tuple[float | None, float | None] = (None, None), render_mode: Literal["plt", "animation"] | None = None, seed: int | None = DEFAULT_SEED, - project_actions_to: Literal["x", "y", "xy"] | None = None, + project_actions_to: Literal["x", "y", "xy", "zy"] | None = None, apply_volume_transformation: bool = False, ): if not name2volume: @@ -122,7 +122,7 @@ def __init__( ) @property - def project_actions_to(self) -> Literal["x", "y", "xy"] | None: + def project_actions_to(self) -> Literal["x", "y", "xy", "zy"] | None: return self._project_actions_to def get_optimal_action(self) -> ManipulatorAction: @@ -146,6 +146,8 @@ def _get_projected_action_arr_idx(self) -> list[int]: return [3] case "xy": return [2, 3] + case "zy": + return [0, 3] case _: raise ValueError(f"Unknown {self._project_actions_to=}") diff --git a/src/armscan_env/wrapper.py b/src/armscan_env/wrapper.py index 2476fd0..5401622 100644 --- a/src/armscan_env/wrapper.py +++ b/src/armscan_env/wrapper.py @@ -318,7 +318,7 @@ def __init__( venv_type: VectorEnvType = VectorEnvType.SUBPROC_SHARED_MEM_AUTO, seed: int | None = None, n_stack: int = 1, - project_actions_to: Literal["x", "y", "xy"] | None = None, + project_actions_to: Literal["x", "y", "xy", "zy"] | None = None, apply_volume_transformation: bool = False, add_reward_details: bool = False, **make_kwargs: Any, From d1c068ccb6bc0292d031b7ee026f01256902adfd Mon Sep 17 00:00:00 2001 From: charliebrownies Date: Mon, 15 Jul 2024 15:34:05 +0200 Subject: [PATCH 30/36] Add more volumes --- data/labels/00013_labels.nii | 3 +++ data/labels/00017_labels.nii | 3 +++ data/labels/00018_labels.nii | 3 +++ data/labels/00029_labels.nii | 3 +++ data/labels/00035_labels.nii | 3 +++ data/labels/00042_labels.nii | 3 +++ data/mri/00013.nii | 3 +++ data/mri/00017.nii | 3 +++ data/mri/00018.nii | 3 +++ data/mri/00029.nii | 3 +++ data/mri/00035.nii | 3 +++ data/mri/00042.nii | 3 +++ 12 files changed, 36 insertions(+) create mode 100644 data/labels/00013_labels.nii create mode 100644 data/labels/00017_labels.nii create mode 100644 data/labels/00018_labels.nii create mode 100644 data/labels/00029_labels.nii create mode 100644 data/labels/00035_labels.nii create mode 100644 data/labels/00042_labels.nii create mode 100644 data/mri/00013.nii create mode 100644 data/mri/00017.nii create mode 100644 data/mri/00018.nii create mode 100644 data/mri/00029.nii create mode 100644 data/mri/00035.nii create mode 100644 data/mri/00042.nii diff --git a/data/labels/00013_labels.nii b/data/labels/00013_labels.nii new file mode 100644 index 0000000..b40c0d1 --- /dev/null +++ b/data/labels/00013_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be5684e3f3d1eea935dab2e43c181fb1588c655251a7fb0d9ab9689d6ab765f0 +size 31938976 diff --git a/data/labels/00017_labels.nii b/data/labels/00017_labels.nii new file mode 100644 index 0000000..24f0d86 --- /dev/null +++ b/data/labels/00017_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1e72cb99f55bc7069c71ab05569aec3e1e0fefb68d14b98f32db2da1529dfb07 +size 44535232 diff --git a/data/labels/00018_labels.nii b/data/labels/00018_labels.nii new file mode 100644 index 0000000..81179de --- /dev/null +++ b/data/labels/00018_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d3f08208578f3193fc59ae58317bae942092494039a3a88282a77de16c907d7 +size 37325152 diff --git a/data/labels/00029_labels.nii b/data/labels/00029_labels.nii new file mode 100644 index 0000000..865983f --- /dev/null +++ b/data/labels/00029_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5d68dd76a2ebd33a8e05d8ae8455446e32f3e7346d86a64dc6a02d8e0e3beca9 +size 21266848 diff --git a/data/labels/00035_labels.nii b/data/labels/00035_labels.nii new file mode 100644 index 0000000..7c6dcae --- /dev/null +++ b/data/labels/00035_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:930dfaa34e51279edabce8aab3c84b83249027845b250ab1e8281787cca18c3f +size 38221984 diff --git a/data/labels/00042_labels.nii b/data/labels/00042_labels.nii new file mode 100644 index 0000000..2150561 --- /dev/null +++ b/data/labels/00042_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a196e70aaca953c4ddad4db8eb5c514d309cd588a3ea735ef67b00942a9b6c +size 42934240 diff --git a/data/mri/00013.nii b/data/mri/00013.nii new file mode 100644 index 0000000..edb05cd --- /dev/null +++ b/data/mri/00013.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee719981e74b9762ff127f8201e213229d5da1b7b4df34c1c73bc090e4a68fc3 +size 63877600 diff --git a/data/mri/00017.nii b/data/mri/00017.nii new file mode 100644 index 0000000..b2b59df --- /dev/null +++ b/data/mri/00017.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e645d0f1e87abf24b03734389ac95e6f1551b7632af9263ecde11c43f2b18cfe +size 89070112 diff --git a/data/mri/00018.nii b/data/mri/00018.nii new file mode 100644 index 0000000..dc6126e --- /dev/null +++ b/data/mri/00018.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b02ac7a1f21a280118fea206aa2a4c7ce637970763bf685e9911ed76559ea1a9 +size 74649952 diff --git a/data/mri/00029.nii b/data/mri/00029.nii new file mode 100644 index 0000000..ba4cb80 --- /dev/null +++ b/data/mri/00029.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:500e14567981fd674e22badbc01b5dd7f45d7bfc54df6f476344526a873286d4 +size 42533344 diff --git a/data/mri/00035.nii b/data/mri/00035.nii new file mode 100644 index 0000000..ff5f388 --- /dev/null +++ b/data/mri/00035.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccbdf8be6546a32c728396f2c1b9b80e4fdd0936507290343d8d2cff14866ad9 +size 76443616 diff --git a/data/mri/00042.nii b/data/mri/00042.nii new file mode 100644 index 0000000..1741d7c --- /dev/null +++ b/data/mri/00042.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:736a85dd7ad5dd398b8e945b782bf80789b4a477631f1ce8851cd3cd064a6c12 +size 85868128 From 09ea1742bbb361de205926b93a498709aa83313a Mon Sep 17 00:00:00 2001 From: charliebrownies Date: Mon, 15 Jul 2024 16:27:50 +0200 Subject: [PATCH 31/36] turned volume --- data/labels/00029_labels.nii | 2 +- data/mri/00029.nii | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/data/labels/00029_labels.nii b/data/labels/00029_labels.nii index 865983f..5401361 100644 --- a/data/labels/00029_labels.nii +++ b/data/labels/00029_labels.nii @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5d68dd76a2ebd33a8e05d8ae8455446e32f3e7346d86a64dc6a02d8e0e3beca9 +oid sha256:cd541c396f88f392125d6e08acb946dd5d06b84429b738dff2c354bbdf1db73c size 21266848 diff --git a/data/mri/00029.nii b/data/mri/00029.nii index ba4cb80..df08827 100644 --- a/data/mri/00029.nii +++ b/data/mri/00029.nii @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:500e14567981fd674e22badbc01b5dd7f45d7bfc54df6f476344526a873286d4 +oid sha256:4c778226ae2599a53e7cd4b98eaa4cf5e5a39dc8ff1a16aba50bbf7bfd5a72c5 size 42533344 From 7362d4fe7c05937bca1623fcb44443d4652eb76b Mon Sep 17 00:00:00 2001 From: charliebrownies Date: Mon, 15 Jul 2024 18:09:53 +0200 Subject: [PATCH 32/36] tuned labelmaps --- data/labels/00017_labels.nii | 2 +- data/labels/00018_labels.nii | 2 +- data/labels/00029_labels.nii | 3 --- data/mri/00029.nii | 3 --- 4 files changed, 2 insertions(+), 8 deletions(-) delete mode 100644 data/labels/00029_labels.nii delete mode 100644 data/mri/00029.nii diff --git a/data/labels/00017_labels.nii b/data/labels/00017_labels.nii index 24f0d86..1ca2818 100644 --- a/data/labels/00017_labels.nii +++ b/data/labels/00017_labels.nii @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e72cb99f55bc7069c71ab05569aec3e1e0fefb68d14b98f32db2da1529dfb07 +oid sha256:f41f3cb3ba38b7dda8031969e51abc70749784dffea66cd8ecc0da6ad85385a9 size 44535232 diff --git a/data/labels/00018_labels.nii b/data/labels/00018_labels.nii index 81179de..4566dc3 100644 --- a/data/labels/00018_labels.nii +++ b/data/labels/00018_labels.nii @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6d3f08208578f3193fc59ae58317bae942092494039a3a88282a77de16c907d7 +oid sha256:287aa932097093b0fcfd9424e82160194f953e51ae52702c400ff97ef6cb3467 size 37325152 diff --git a/data/labels/00029_labels.nii b/data/labels/00029_labels.nii deleted file mode 100644 index 5401361..0000000 --- a/data/labels/00029_labels.nii +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd541c396f88f392125d6e08acb946dd5d06b84429b738dff2c354bbdf1db73c -size 21266848 diff --git a/data/mri/00029.nii b/data/mri/00029.nii deleted file mode 100644 index df08827..0000000 --- a/data/mri/00029.nii +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4c778226ae2599a53e7cd4b98eaa4cf5e5a39dc8ff1a16aba50bbf7bfd5a72c5 -size 42533344 From f659d59a76c15d1ffa969ffbe164163c6e74297f Mon Sep 17 00:00:00 2001 From: charliebrownies Date: Mon, 15 Jul 2024 15:34:05 +0200 Subject: [PATCH 33/36] Add more volumes --- data/labels/00013_labels.nii | 3 +++ data/labels/00017_labels.nii | 3 +++ data/labels/00018_labels.nii | 3 +++ data/labels/00035_labels.nii | 3 +++ data/labels/00042_labels.nii | 3 +++ data/mri/00013.nii | 3 +++ data/mri/00017.nii | 3 +++ data/mri/00018.nii | 3 +++ data/mri/00035.nii | 3 +++ data/mri/00042.nii | 3 +++ 10 files changed, 30 insertions(+) create mode 100644 data/labels/00013_labels.nii create mode 100644 data/labels/00017_labels.nii create mode 100644 data/labels/00018_labels.nii create mode 100644 data/labels/00035_labels.nii create mode 100644 data/labels/00042_labels.nii create mode 100644 data/mri/00013.nii create mode 100644 data/mri/00017.nii create mode 100644 data/mri/00018.nii create mode 100644 data/mri/00035.nii create mode 100644 data/mri/00042.nii diff --git a/data/labels/00013_labels.nii b/data/labels/00013_labels.nii new file mode 100644 index 0000000..b40c0d1 --- /dev/null +++ b/data/labels/00013_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be5684e3f3d1eea935dab2e43c181fb1588c655251a7fb0d9ab9689d6ab765f0 +size 31938976 diff --git a/data/labels/00017_labels.nii b/data/labels/00017_labels.nii new file mode 100644 index 0000000..1ca2818 --- /dev/null +++ b/data/labels/00017_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f41f3cb3ba38b7dda8031969e51abc70749784dffea66cd8ecc0da6ad85385a9 +size 44535232 diff --git a/data/labels/00018_labels.nii b/data/labels/00018_labels.nii new file mode 100644 index 0000000..4566dc3 --- /dev/null +++ b/data/labels/00018_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:287aa932097093b0fcfd9424e82160194f953e51ae52702c400ff97ef6cb3467 +size 37325152 diff --git a/data/labels/00035_labels.nii b/data/labels/00035_labels.nii new file mode 100644 index 0000000..7c6dcae --- /dev/null +++ b/data/labels/00035_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:930dfaa34e51279edabce8aab3c84b83249027845b250ab1e8281787cca18c3f +size 38221984 diff --git a/data/labels/00042_labels.nii b/data/labels/00042_labels.nii new file mode 100644 index 0000000..2150561 --- /dev/null +++ b/data/labels/00042_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a196e70aaca953c4ddad4db8eb5c514d309cd588a3ea735ef67b00942a9b6c +size 42934240 diff --git a/data/mri/00013.nii b/data/mri/00013.nii new file mode 100644 index 0000000..edb05cd --- /dev/null +++ b/data/mri/00013.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee719981e74b9762ff127f8201e213229d5da1b7b4df34c1c73bc090e4a68fc3 +size 63877600 diff --git a/data/mri/00017.nii b/data/mri/00017.nii new file mode 100644 index 0000000..b2b59df --- /dev/null +++ b/data/mri/00017.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e645d0f1e87abf24b03734389ac95e6f1551b7632af9263ecde11c43f2b18cfe +size 89070112 diff --git a/data/mri/00018.nii b/data/mri/00018.nii new file mode 100644 index 0000000..dc6126e --- /dev/null +++ b/data/mri/00018.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b02ac7a1f21a280118fea206aa2a4c7ce637970763bf685e9911ed76559ea1a9 +size 74649952 diff --git a/data/mri/00035.nii b/data/mri/00035.nii new file mode 100644 index 0000000..ff5f388 --- /dev/null +++ b/data/mri/00035.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccbdf8be6546a32c728396f2c1b9b80e4fdd0936507290343d8d2cff14866ad9 +size 76443616 diff --git a/data/mri/00042.nii b/data/mri/00042.nii new file mode 100644 index 0000000..1741d7c --- /dev/null +++ b/data/mri/00042.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:736a85dd7ad5dd398b8e945b782bf80789b4a477631f1ce8851cd3cd064a6c12 +size 85868128 From 511a051fb6e852c5fdb21b9fac89e4023e8d650d Mon Sep 17 00:00:00 2001 From: charliebrownies Date: Thu, 18 Jul 2024 10:15:48 +0200 Subject: [PATCH 34/36] add volume 11 --- data/labels/00011_labels.nii | 3 +++ data/mri/00011.nii | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 data/labels/00011_labels.nii create mode 100644 data/mri/00011.nii diff --git a/data/labels/00011_labels.nii b/data/labels/00011_labels.nii new file mode 100644 index 0000000..c273da3 --- /dev/null +++ b/data/labels/00011_labels.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:73c786909448c1e7b98684ffd4113bf8fa8d806ea57af1adf3db9e138ef64c82 +size 18556512 diff --git a/data/mri/00011.nii b/data/mri/00011.nii new file mode 100644 index 0000000..1e758ce --- /dev/null +++ b/data/mri/00011.nii @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea91a4f900a577a971c4f7c5b7c0c5a0a2257b8a70325935b932a0f86a604354 +size 37112672 From 70d9de8819ab59e6a25adbf8ad86a76b25150c34 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Thu, 18 Jul 2024 11:47:07 +0200 Subject: [PATCH 35/36] Create ImageVolume class for labelmaps with set optimal action Change loading mechanism using RegisteredLabelmap Project action only when transforming it --- docs/02_notebooks/L0_MRI_and_Labelmaps.ipynb | 4 +- docs/02_notebooks/L1_simple_clustering.ipynb | 2 +- docs/02_notebooks/L2_DBSCAN_clustering.ipynb | 2 +- docs/02_notebooks/L3_slicing.ipynb | 15 +- docs/02_notebooks/L4_environment.ipynb | 62 +--- docs/02_notebooks/L5_linear_sweep.ipynb | 48 +++ notebooks/normalized_volumes.ipynb | 78 ++++- notebooks/random_volume_transformations.ipynb | 293 ++---------------- scripts/armscan_array_obs.py | 6 +- scripts/armscan_debugging.py | 8 +- scripts/armscan_dqn_sac_hl.py | 6 +- scripts/armscan_ppo_hl.py | 6 +- scripts/armscan_sac_hl.py | 6 +- src/armscan_env/config.py | 36 ++- src/armscan_env/envs/labelmaps_navigation.py | 41 +-- src/armscan_env/envs/rewards.py | 2 +- src/armscan_env/envs/state_action.py | 32 +- src/armscan_env/volumes/loading.py | 76 ++++- src/armscan_env/volumes/slicing.py | 195 ------------ src/armscan_env/volumes/volumes.py | 285 +++++++++++++++++ test/armscan_env/test_labelmap_volumes.py | 34 +- 21 files changed, 587 insertions(+), 650 deletions(-) delete mode 100644 src/armscan_env/volumes/slicing.py create mode 100644 src/armscan_env/volumes/volumes.py diff --git a/docs/02_notebooks/L0_MRI_and_Labelmaps.ipynb b/docs/02_notebooks/L0_MRI_and_Labelmaps.ipynb index 9e61b39..d39fff0 100644 --- a/docs/02_notebooks/L0_MRI_and_Labelmaps.ipynb +++ b/docs/02_notebooks/L0_MRI_and_Labelmaps.ipynb @@ -62,7 +62,7 @@ "metadata": {}, "outputs": [], "source": [ - "mri_1 = sitk.ReadImage(config.get_mri_path(1))\n", + "mri_1 = sitk.ReadImage(config.get_single_mri_path(1))\n", "mri_1_data = sitk.GetArrayFromImage(mri_1)\n", "print(f\"{mri_1_data.shape=}\")" ] @@ -106,7 +106,7 @@ "metadata": {}, "outputs": [], "source": [ - "mri_1_label = sitk.ReadImage(config.get_labels_path(1))\n", + "mri_1_label = sitk.ReadImage(config.get_single_labelmap_path(1))\n", "mri_1_label_data = sitk.GetArrayFromImage(mri_1_label)\n", "print(f\"{mri_1_label_data.shape =}\")" ] diff --git a/docs/02_notebooks/L1_simple_clustering.ipynb b/docs/02_notebooks/L1_simple_clustering.ipynb index 7197cc1..5645f99 100644 --- a/docs/02_notebooks/L1_simple_clustering.ipynb +++ b/docs/02_notebooks/L1_simple_clustering.ipynb @@ -53,7 +53,7 @@ "metadata": {}, "outputs": [], "source": [ - "mri_1_label = sitk.ReadImage(config.get_labels_path(1))\n", + "mri_1_label = sitk.ReadImage(config.get_single_labelmap_path(1))\n", "mri_1_label_data = sitk.GetArrayFromImage(mri_1_label)\n", "print(f\"{mri_1_label_data.shape=}\")" ] diff --git a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb index 416de6a..55d6893 100644 --- a/docs/02_notebooks/L2_DBSCAN_clustering.ipynb +++ b/docs/02_notebooks/L2_DBSCAN_clustering.ipynb @@ -52,7 +52,7 @@ "metadata": {}, "outputs": [], "source": [ - "path_to_mri = config.get_labels_path(1)\n", + "path_to_mri = config.get_single_labelmap_path(1)\n", "mri_1_label = sitk.ReadImage(path_to_mri)\n", "mri_1_label_data = sitk.GetArrayFromImage(mri_1_label)\n", "print(f\"{mri_1_label_data.shape=}\")" diff --git a/docs/02_notebooks/L3_slicing.ipynb b/docs/02_notebooks/L3_slicing.ipynb index 689052a..b29e6af 100644 --- a/docs/02_notebooks/L3_slicing.ipynb +++ b/docs/02_notebooks/L3_slicing.ipynb @@ -39,7 +39,7 @@ "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.slicing import get_volume_slice\n", + "from armscan_env.volumes.volumes import ImageVolume\n", "from celluloid import Camera\n", "from IPython.core.display import HTML\n", "\n", @@ -52,7 +52,7 @@ "metadata": {}, "outputs": [], "source": [ - "volume = sitk.ReadImage(config.get_labels_path(1))\n", + "volume = sitk.ReadImage(config.get_single_labelmap_path(1))\n", "img_array = sitk.GetArrayFromImage(volume)\n", "print(f\"{img_array.shape=}\")" ] @@ -257,12 +257,12 @@ "metadata": {}, "outputs": [], "source": [ - "sliced_volume = get_volume_slice(\n", + "volume = ImageVolume(volume)\n", + "sliced_volume = volume.get_volume_slice(\n", " action=ManipulatorAction(rotation=(19.3, 0), translation=(0, 140)),\n", - " volume=volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", - "sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", + "sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", "\n", "slice = sliced_img\n", @@ -314,15 +314,14 @@ " ax1.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", " # Subplot 2: Function image\n", - " sliced_volume = get_volume_slice(\n", - " volume=volume,\n", + " sliced_volume = volume.get_volume_slice(\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", " action=ManipulatorAction(\n", " rotation=(z[i], 0),\n", " translation=(0, t[i]),\n", " ),\n", " )\n", - " sliced_img = sitk.GetArrayFromImage(sliced_volume)[:, 0, :]\n", + " sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", " ax2.set_title(f\"Slice {i}\")\n", " ax2.imshow(sliced_img, aspect=6)\n", " camera.snap()\n", diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index 79cd615..b17d859 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -42,14 +42,12 @@ " LabelmapEnvTerminationCriterion,\n", ")\n", "from armscan_env.envs.observations import (\n", - " ActionRewardObservation,\n", " LabelmapSliceAsChannelsObservation,\n", ")\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", "from armscan_env.volumes.loading import load_sitk_volumes\n", - "from armscan_env.volumes.slicing import get_volume_slice\n", "from celluloid import Camera\n", "from IPython.core.display import HTML\n", "\n", @@ -116,8 +114,7 @@ " ax1.set_title(\"Slice cut\")\n", "\n", " # ACTION\n", - " sliced_volume = get_volume_slice(\n", - " volume=volumes[0],\n", + " sliced_volume = volumes[0].get_volume_slice(\n", " slice_shape=slice_shape,\n", " action=ManipulatorAction(rotation=(z[i], 0.0), translation=(0.0, t[i])),\n", " )\n", @@ -214,62 +211,7 @@ " if terminated or truncated:\n", " observation, info = env.reset(reset_render=False)\n", "animation = env.get_cur_animation()\n", - "env.close()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "HTML(animation.to_jshtml())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "volume_size = volumes[0].GetSize()\n", - "\n", - "env = LabelmapEnv(\n", - " name2volume={\"1\": volumes[0]},\n", - " observation=ActionRewardObservation(action_shape=(2,)).to_array_observation(),\n", - " slice_shape=(volume_size[0], volume_size[2]),\n", - " reward_metric=LabelmapClusteringBasedReward(),\n", - " termination_criterion=LabelmapEnvTerminationCriterion(),\n", - " max_episode_len=10,\n", - " rotation_bounds=(30.0, 10.0),\n", - " translation_bounds=(None, None),\n", - " render_mode=\"animation\",\n", - " project_actions_to=\"zy\",\n", - " apply_volume_transformation=True,\n", - ")\n", - "\n", - "observation, info = env.reset()\n", - "for _ in range(50):\n", - " action = env.action_space.sample()\n", - " epsilon = 0.1\n", - " if np.random.rand() > epsilon:\n", - " observation, reward, terminated, truncated, info = env.step(action)\n", - " else:\n", - " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", - " env.render()\n", - "\n", - " if terminated or truncated:\n", - " observation, info = env.reset(reset_render=False)\n", - "animation = env.get_cur_animation()\n", - "env.close()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ + "env.close()\n", "HTML(animation.to_jshtml())" ] }, diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index f6d462d..b6e94e2 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -246,6 +246,54 @@ "source": [ "projected_env.get_cur_animation_as_html()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "37f3d415b00f01a2", + "metadata": {}, + "outputs": [], + "source": [ + "volume_size = volumes[0].GetSize()\n", + "\n", + "env = LabelmapEnv(\n", + " name2volume={\"1\": volumes[0]},\n", + " observation=ActionRewardObservation(action_shape=(2,)).to_array_observation(),\n", + " slice_shape=(volume_size[0], volume_size[2]),\n", + " reward_metric=LabelmapClusteringBasedReward(),\n", + " termination_criterion=LabelmapEnvTerminationCriterion(),\n", + " max_episode_len=10,\n", + " rotation_bounds=(30.0, 10.0),\n", + " translation_bounds=(None, None),\n", + " render_mode=\"animation\",\n", + " project_actions_to=\"zy\",\n", + " apply_volume_transformation=True,\n", + ")\n", + "\n", + "observation, info = env.reset()\n", + "for _ in range(50):\n", + " action = env.action_space.sample()\n", + " epsilon = 0.1\n", + " if np.random.rand() > epsilon:\n", + " observation, reward, terminated, truncated, info = env.step(action)\n", + " else:\n", + " print(f\"taken optimal action: {env.get_optimal_action()}\")\n", + " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", + " env.render()\n", + "\n", + " if terminated or truncated:\n", + " observation, info = env.reset(reset_render=False)\n", + "animation = env.get_cur_animation()\n", + "env.get_cur_animation_as_html()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "93c3bab90c4ed51", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/notebooks/normalized_volumes.ipynb b/notebooks/normalized_volumes.ipynb index 456c24a..82ad96b 100644 --- a/notebooks/normalized_volumes.ipynb +++ b/notebooks/normalized_volumes.ipynb @@ -39,6 +39,17 @@ "normalized_volumes = resize_sitk_volume(volumes)" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "f0711576d1591e1e", + "metadata": {}, + "outputs": [], + "source": [ + "print(volumes[5].GetSize())\n", + "print(normalized_volumes[1].GetSize())" + ] + }, { "cell_type": "code", "execution_count": null, @@ -46,22 +57,77 @@ "metadata": {}, "outputs": [], "source": [ - "array = sitk.GetArrayFromImage(normalized_volumes[1])\n", - "plt.imshow(array[40, :, :])\n", + "volume = normalized_volumes[6]\n", + "array = sitk.GetArrayFromImage(volume)\n", "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" ] }, { "cell_type": "code", "execution_count": null, - "id": "2f8c0a05160a98e3", + "id": "a3c66c97f44e6e79", "metadata": {}, "outputs": [], "source": [ - "array = sitk.GetArrayFromImage(volumes[0])\n", - "plt.imshow(array[40, :, :])\n", - "print(f\"Slice value range: {np.min(array)} - {np.max(array)}\")" + "from armscan_env.clustering import TissueClusters\n", + "from armscan_env.envs.rewards import anatomy_based_rwd\n", + "from armscan_env.envs.state_action import ManipulatorAction\n", + "from armscan_env.util.visualizations import show_clusters\n", + "\n", + "action = ManipulatorAction(rotation=(-3, 0), translation=(0, 178))\n", + "x_size_2, y_size_2, z_size_2 = (\n", + " sz * sp for sz, sp in zip(volume.GetSize(), volume.GetSpacing(), strict=True)\n", + ")\n", + "extent_xy_2 = (0, x_size_2, y_size_2, 0)\n", + "\n", + "spacing = volume.GetSpacing()\n", + "plt.imshow(array[33, :, :], extent=extent_xy_2)\n", + "\n", + "o = volume.GetOrigin()\n", + "x_dash = np.arange(x_size_2)\n", + "b = action.translation[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", + "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", + "\n", + "plt.show()" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4bbd55164937cbbe", + "metadata": {}, + "outputs": [], + "source": [ + "slice_shape = (volume.GetSize()[0], volume.GetSize()[2])\n", + "sliced_volume = volume.get_volume_slice(\n", + " slice_shape=slice_shape,\n", + " action=action,\n", + ")\n", + "sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", + "cluster = TissueClusters.from_labelmap_slice(sliced_img.T)\n", + "show_clusters(cluster, sliced_img.T, aspect=4)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7b8d2c53c0a690a", + "metadata": {}, + "outputs": [], + "source": [ + "reward = anatomy_based_rwd(cluster)\n", + "print(reward)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ac318751be6d064", + "metadata": {}, + "outputs": [], + "source": [] } ], "metadata": { diff --git a/notebooks/random_volume_transformations.ipynb b/notebooks/random_volume_transformations.ipynb index b369b1d..a63baf9 100644 --- a/notebooks/random_volume_transformations.ipynb +++ b/notebooks/random_volume_transformations.ipynb @@ -26,12 +26,11 @@ "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.slicing import (\n", - " create_transformed_volume,\n", - " get_volume_slice,\n", - ")\n", + "from armscan_env.volumes.loading import load_sitk_volumes\n", + "from armscan_env.volumes.volumes import TransformedVolume\n", "\n", - "config = config.get_config()" + "config = config.get_config()\n", + "volumes = load_sitk_volumes(normalize=True)" ] }, { @@ -41,7 +40,7 @@ "metadata": {}, "outputs": [], "source": [ - "volume = sitk.ReadImage(config.get_labels_path(1))\n", + "volume = volumes[4]\n", "volume_img = sitk.GetArrayFromImage(volume)\n", "\n", "x_size, y_size, z_size = (\n", @@ -50,12 +49,11 @@ "extent_xy = (0, x_size, y_size, 0)\n", "\n", "plt.imshow(volume_img[40, :, :], extent=extent_xy)\n", - "action = ManipulatorAction(rotation=(19, 0), translation=(0, 140))\n", "\n", "o = volume.GetOrigin()\n", "x_dash = np.arange(x_size)\n", - "b = action.translation[1]\n", - "y_dash = x_dash * np.tan(np.deg2rad(action.rotation[0])) + b\n", + "b = volume.optimal_action.translation[1]\n", + "y_dash = x_dash * np.tan(np.deg2rad(volume.optimal_action.rotation[0])) + b\n", "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", "\n", "plt.show()" @@ -65,148 +63,45 @@ "cell_type": "code", "execution_count": null, "id": "29c544e76d3f6144", - "metadata": { - "jupyter": { - "is_executing": true - } - }, + "metadata": {}, "outputs": [], "source": [ - "sliced_volume = get_volume_slice(\n", - " action=action,\n", - " volume=volume,\n", + "sliced_volume = volume.get_volume_slice(\n", + " action=volume.optimal_action,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", "sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", "print(f\"Slice value range: {np.min(sliced_img)} - {np.max(sliced_img)}\")\n", "\n", "extent_xz = (0, x_size, z_size, 0)\n", - "plt.imshow(sliced_img, extent=extent_xz)\n", + "cluster = TissueClusters.from_labelmap_slice(sliced_img.T)\n", + "show_clusters(cluster, sliced_img.T)\n", + "reward = anatomy_based_rwd(cluster)\n", + "print(f\"Reward: {reward}\")\n", "plt.show()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "6803472d7529d369", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "transform = sitk.Euler3DTransform()\n", - "transform.SetRotation(0, 0, np.deg2rad(19))\n", - "transform.SetTranslation((0, 10, 0))\n", - "transform.SetCenter(volume.GetOrigin())\n", - "resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID())\n", - "plt.imshow(sitk.GetArrayFromImage(resampled)[40, :, :], extent=extent_xy)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "677890fe4c481d25", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "transformation_action = ManipulatorAction(rotation=(19, 0), translation=(0, 140))\n", - "relative_action = ManipulatorAction(rotation=(0, 0), translation=(0, 0))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "638f0b403ea85e68", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "volume_rotation = np.deg2rad(transformation_action.rotation)\n", - "volume_translation = transformation_action.translation\n", - "\n", - "volume_transform = sitk.Euler3DTransform()\n", - "volume_transform.SetRotation(volume_rotation[1], 0, volume_rotation[0])\n", - "volume_transform.SetTranslation((*volume_translation, 0))\n", - "\n", - "inverse_volume_transform = volume_transform.GetInverse()\n", - "inverse_volume_transform_matrix = np.eye(4)\n", - "inverse_volume_transform_matrix[:3, :3] = np.array(inverse_volume_transform.GetMatrix()).reshape(\n", - " 3,\n", - " 3,\n", - ")\n", - "inverse_volume_transform_matrix[:3, 3] = inverse_volume_transform.GetTranslation()\n", - "\n", - "action_rotation = np.deg2rad(relative_action.rotation)\n", - "action_translation = relative_action.translation\n", - "action_transform = sitk.Euler3DTransform()\n", - "action_transform.SetRotation(action_rotation[1], 0, action_rotation[0])\n", - "action_transform.SetTranslation((*action_translation, 0))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5ecf961f33977811", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "composite = sitk.CompositeTransform(3)\n", - "composite.AddTransform(inverse_volume_transform)\n", - "composite.AddTransform(action_transform)" - ] - }, { "cell_type": "code", "execution_count": null, "id": "26ffcc6d7dece611", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "volume_transformation = ManipulatorAction(rotation=(19, 0), translation=(-9.74, -4.31))\n", - "transformed_volume = create_transformed_volume(volume, volume_transformation)\n", - "transformed_action = transformed_volume.transform_action(action)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a54ca3679f89423e", - "metadata": { - "jupyter": { - "is_executing": true - } - }, + "metadata": {}, "outputs": [], "source": [ - "print(f\"{action=}\\n{transformed_action=}\\n\")" + "volume_transformation = ManipulatorAction(\n", + " rotation=(-7.213170270886784, 0.0),\n", + " translation=(-7.31243280019082, 9.172539411055304),\n", + ")\n", + "transformed_volume = TransformedVolume.create_transformed_volume(volume, volume_transformation)\n", + "transformed_action = transformed_volume.optimal_action\n", + "print(f\"{volume.optimal_action=}\\n{transformed_volume.optimal_action=}\\n\")" ] }, { "cell_type": "code", "execution_count": null, "id": "db20a3ff9556e8b4", - "metadata": { - "jupyter": { - "is_executing": true - } - }, + "metadata": {}, "outputs": [], "source": [ "transformed_img = sitk.GetArrayFromImage(transformed_volume)\n", @@ -226,36 +121,16 @@ "cell_type": "code", "execution_count": null, "id": "acda09e94c3f2f2b", - "metadata": { - "jupyter": { - "is_executing": true - } - }, + "metadata": {}, "outputs": [], "source": [ - "sliced_transformed_volume = get_volume_slice(\n", + "sliced_transformed_volume = transformed_volume.get_volume_slice(\n", " action=transformed_action,\n", - " volume=transformed_volume,\n", " slice_shape=(volume.GetSize()[0], volume.GetSize()[2]),\n", ")\n", "sliced_transformed_img = sitk.GetArrayFromImage(sliced_transformed_volume)\n", "print(f\"Slice value range: {np.min(sliced_transformed_img)} - {np.max(sliced_transformed_img)}\")\n", "\n", - "plt.imshow(sliced_transformed_img, extent=extent_xz)\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7daab6fa59e5c75", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ "cluster = TissueClusters.from_labelmap_slice(sliced_transformed_img.T)\n", "show_clusters(cluster, sliced_transformed_img.T)\n", "reward = anatomy_based_rwd(cluster)\n", @@ -266,122 +141,10 @@ { "cell_type": "code", "execution_count": null, - "id": "fb6cfecff1cb7cd4", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "volume_2 = sitk.ReadImage(config.get_labels_path(2))\n", - "volume_2_img = sitk.GetArrayFromImage(volume_2)\n", - "x_size_2, y_size_2, z_size_2 = (\n", - " sz * sp for sz, sp in zip(volume_2.GetSize(), volume_2.GetSpacing(), strict=True)\n", - ")\n", - "extent_xy_2 = (0, x_size_2, y_size_2, 0)\n", - "\n", - "spacing = volume_2.GetSpacing()\n", - "plt.imshow(volume_2_img[51, :, :], extent=extent_xy_2)\n", - "action_2 = ManipulatorAction(rotation=(5, 0), translation=(0, 112))\n", - "\n", - "o = volume_2.GetOrigin()\n", - "x_dash = np.arange(x_size_2)\n", - "b = action_2.translation[1]\n", - "y_dash = x_dash * np.tan(np.deg2rad(action_2.rotation[0])) + b\n", - "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", - "\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6462b823c7903838", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "sliced_volume_2 = get_volume_slice(\n", - " action=action_2,\n", - " volume=volume_2,\n", - " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", - ")\n", - "sliced_img_2 = sitk.GetArrayFromImage(sliced_volume_2)\n", - "\n", - "cluster = TissueClusters.from_labelmap_slice(sliced_img_2.T)\n", - "show_clusters(cluster, sliced_img_2.T, aspect=spacing[2] / spacing[0])\n", - "\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "38dc246ec22f0835", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "volume_transformation_2 = ManipulatorAction(rotation=(10, 0), translation=(-9.74, -4.31))\n", - "transformed_volume_2 = create_transformed_volume(volume_2, volume_transformation_2)\n", - "transformed_action_2 = transformed_volume_2.transform_action(action_2)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5504f6f5d6ad9f5", - "metadata": { - "jupyter": { - "is_executing": true - } - }, - "outputs": [], - "source": [ - "transformed_img_2 = sitk.GetArrayFromImage(transformed_volume_2)\n", - "\n", - "plt.imshow(transformed_img_2[51, :, :], extent=extent_xy_2)\n", - "\n", - "x_dash = np.arange(x_size_2)\n", - "b = transformed_action_2.translation[1]\n", - "y_dash = x_dash * np.tan(np.deg2rad(transformed_action_2.rotation[0])) + b\n", - "plt.plot(x_dash, y_dash, linestyle=\"--\", color=\"red\")\n", - "\n", - "plt.show()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a47159b4af236854", - "metadata": { - "jupyter": { - "is_executing": true - } - }, + "id": "ede101a8010b0bd5", + "metadata": {}, "outputs": [], - "source": [ - "sliced_transformed_volume_2 = get_volume_slice(\n", - " action=transformed_action_2,\n", - " volume=transformed_volume_2,\n", - " slice_shape=(volume_2.GetSize()[0], volume_2.GetSize()[2]),\n", - ")\n", - "sliced_transformed_img_2 = sitk.GetArrayFromImage(sliced_transformed_volume_2)\n", - "\n", - "cluster = TissueClusters.from_labelmap_slice(sliced_transformed_img_2.T)\n", - "show_clusters(cluster, sliced_transformed_img_2.T, aspect=spacing[2] / spacing[0])\n", - "reward = anatomy_based_rwd(cluster)\n", - "print(f\"Reward: {reward}\")\n", - "\n", - "plt.show()" - ] + "source": [] } ], "metadata": { diff --git a/scripts/armscan_array_obs.py b/scripts/armscan_array_obs.py index 8d57958..2a420da 100644 --- a/scripts/armscan_array_obs.py +++ b/scripts/armscan_array_obs.py @@ -1,7 +1,6 @@ import logging import os -import SimpleITK as sitk from armscan_env.config import get_config from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion from armscan_env.envs.observations import ( @@ -9,6 +8,7 @@ LabelmapClusterObservation, ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward +from armscan_env.volumes.loading import RegisteredLabelmap from armscan_env.wrapper import ArmscanEnvFactory from tianshou.highlevel.config import SamplingConfig @@ -25,8 +25,8 @@ config = get_config() logging.basicConfig(level=logging.INFO) - volume_1 = sitk.ReadImage(config.get_labels_path(1)) - volume_2 = sitk.ReadImage(config.get_labels_path(2)) + volume_1 = RegisteredLabelmap.v1.load_labelmap() + volume_2 = RegisteredLabelmap.v2.load_labelmap() log_name = os.path.join("sac-characteristic-array", str(ExperimentConfig.seed), datetime_tag()) experiment_config = ExperimentConfig() diff --git a/scripts/armscan_debugging.py b/scripts/armscan_debugging.py index 085af1b..2ed17ed 100644 --- a/scripts/armscan_debugging.py +++ b/scripts/armscan_debugging.py @@ -1,7 +1,6 @@ import logging import os -import SimpleITK as sitk from armscan_env.config import get_config from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion from armscan_env.envs.observations import ( @@ -9,6 +8,7 @@ LabelmapClusterObservation, ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward +from armscan_env.volumes.loading import RegisteredLabelmap from armscan_env.wrapper import ArmscanEnvFactory from tianshou.highlevel.config import SamplingConfig @@ -25,8 +25,8 @@ config = get_config() logging.basicConfig(level=logging.INFO) - volume_1 = sitk.ReadImage(config.get_labels_path(1)) - volume_2 = sitk.ReadImage(config.get_labels_path(2)) + volume_1 = RegisteredLabelmap.v1.load_labelmap() + volume_2 = RegisteredLabelmap.v2.load_labelmap() log_name = os.path.join("sac-characteristic-array", str(ExperimentConfig.seed), datetime_tag()) experiment_config = ExperimentConfig() @@ -53,7 +53,7 @@ slice_shape=(volume_size[0], volume_size[2]), max_episode_len=20, rotation_bounds=(90.0, 45.0), - translation_bounds=(0.0, None), + translation_bounds=(None, None), render_mode="animation", seed=experiment_config.seed, venv_type=VectorEnvType.DUMMY, diff --git a/scripts/armscan_dqn_sac_hl.py b/scripts/armscan_dqn_sac_hl.py index 24e9699..ad96251 100644 --- a/scripts/armscan_dqn_sac_hl.py +++ b/scripts/armscan_dqn_sac_hl.py @@ -1,7 +1,6 @@ import logging import os -import SimpleITK as sitk from armscan_env.config import get_config from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion from armscan_env.envs.observations import ( @@ -9,6 +8,7 @@ ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.network import ActorFactoryArmscanDQN +from armscan_env.volumes.loading import RegisteredLabelmap from armscan_env.wrapper import ArmscanEnvFactory from tianshou.highlevel.config import SamplingConfig @@ -25,8 +25,8 @@ config = get_config() logging.basicConfig(level=logging.INFO) - volume_1 = sitk.ReadImage(config.get_labels_path(1)) - volume_2 = sitk.ReadImage(config.get_labels_path(2)) + volume_1 = RegisteredLabelmap.v1.load_labelmap() + volume_2 = RegisteredLabelmap.v2.load_labelmap() log_name = os.path.join("sac-dqn", str(ExperimentConfig.seed), datetime_tag()) experiment_config = ExperimentConfig() diff --git a/scripts/armscan_ppo_hl.py b/scripts/armscan_ppo_hl.py index d74d527..26dfcd2 100644 --- a/scripts/armscan_ppo_hl.py +++ b/scripts/armscan_ppo_hl.py @@ -1,7 +1,6 @@ import logging import os -import SimpleITK as sitk from armscan_env.config import get_config from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion from armscan_env.envs.observations import ( @@ -9,6 +8,7 @@ ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.network import ActorFactoryArmscanDQN +from armscan_env.volumes.loading import RegisteredLabelmap from armscan_env.wrapper import ArmscanEnvFactory from tianshou.highlevel.config import SamplingConfig @@ -24,8 +24,8 @@ config = get_config() logging.basicConfig(level=logging.INFO) - volume_1 = sitk.ReadImage(config.get_labels_path(1)) - volume_2 = sitk.ReadImage(config.get_labels_path(2)) + volume_1 = RegisteredLabelmap.v1.load_labelmap() + volume_2 = RegisteredLabelmap.v2.load_labelmap() log_name = os.path.join( "ppo", diff --git a/scripts/armscan_sac_hl.py b/scripts/armscan_sac_hl.py index e3b8e58..baff356 100644 --- a/scripts/armscan_sac_hl.py +++ b/scripts/armscan_sac_hl.py @@ -1,13 +1,13 @@ import logging import os -import SimpleITK as sitk from armscan_env.config import get_config from armscan_env.envs.labelmaps_navigation import LabelmapEnvTerminationCriterion from armscan_env.envs.observations import ( ActionRewardObservation, ) from armscan_env.envs.rewards import LabelmapClusteringBasedReward +from armscan_env.volumes.loading import RegisteredLabelmap from armscan_env.wrapper import ArmscanEnvFactory from tianshou.highlevel.config import SamplingConfig @@ -24,8 +24,8 @@ config = get_config() logging.basicConfig(level=logging.INFO) - volume_1 = sitk.ReadImage(config.get_labels_path(1)) - volume_2 = sitk.ReadImage(config.get_labels_path(2)) + volume_1 = RegisteredLabelmap.v1.load_labelmap() + volume_2 = RegisteredLabelmap.v2.load_labelmap() log_name = os.path.join("sac", str(ExperimentConfig.seed), datetime_tag()) experiment_config = ExperimentConfig() diff --git a/src/armscan_env/config.py b/src/armscan_env/config.py index a6bba3b..b1dfe5e 100644 --- a/src/armscan_env/config.py +++ b/src/armscan_env/config.py @@ -8,11 +8,26 @@ class __Configuration(DefaultDataConfiguration): - def get_labels_path(self, labelmap_number: int) -> str: - labelmap_path = os.path.join(self.get_labels_basedir(), f"{labelmap_number:05d}_labels.nii") - return self._adjusted_path(labelmap_path, relative=False, check_existence=True) + def get_labelmap_file_ids(self) -> list[int]: + labelmaps_dir = self.get_labelmaps_basedir() + labels_numbers = sorted( + [f[:5] for f in os.listdir(labelmaps_dir) if f.endswith("_labels.nii")], + ) + return [int(f.lstrip("0")) for f in labels_numbers] + + def get_single_labelmap_path(self, labelmap_file_id: int) -> str: + single_labelmap_path = os.path.join( + self.get_labelmaps_basedir(), + f"{labelmap_file_id:05d}_labels.nii", + ) + return self._adjusted_path(single_labelmap_path, relative=False, check_existence=True) - def get_labels_basedir(self) -> str: + def get_labelmaps_path(self) -> list[str]: + labelmaps_dir = self.get_labelmaps_basedir() + labels_names = sorted([f for f in os.listdir(labelmaps_dir) if f.endswith("_labels.nii")]) + return [os.path.join(labelmaps_dir, labelmap_name) for labelmap_name in labels_names] + + def get_labelmaps_basedir(self) -> str: return self._adjusted_path( os.path.join(self.data, "labels"), relative=False, @@ -20,14 +35,19 @@ def get_labels_basedir(self) -> str: ) def count_labels(self) -> int: - labels_dir = self.get_labels_basedir() + labels_dir = self.get_labelmaps_basedir() return len( [f for f in os.listdir(labels_dir) if os.path.isfile(os.path.join(labels_dir, f))], ) - def get_mri_path(self, mri_number: int) -> str: - mri_path = os.path.join(self.get_mri_basedir(), f"{mri_number:05d}.nii") - return self._adjusted_path(mri_path, relative=False, check_existence=True) + def get_single_mri_path(self, mri_number: int) -> str: + single_mri_path = os.path.join(self.get_mri_basedir(), f"{mri_number:05d}.nii") + return self._adjusted_path(single_mri_path, relative=False, check_existence=True) + + def get_mri_path(self) -> list[str]: + mri_dir = self.get_mri_basedir() + mri_names = sorted([f for f in os.listdir(mri_dir) if f.endswith(".nii")]) + return [os.path.join(mri_dir, labelmap_name) for labelmap_name in mri_names] def get_mri_basedir(self) -> str: return self._adjusted_path( diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index 9e6a9b6..e81187a 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -1,6 +1,6 @@ import logging from abc import ABC -from copy import copy, deepcopy +from copy import copy from typing import Any, ClassVar, Literal import numpy as np @@ -16,9 +16,9 @@ from armscan_env.envs.rewards import LabelmapClusteringBasedReward from armscan_env.envs.state_action import LabelmapStateAction, ManipulatorAction from armscan_env.util.visualizations import show_clusters -from armscan_env.volumes.slicing import ( - create_transformed_volume, - get_volume_slice, +from armscan_env.volumes.volumes import ( + ImageVolume, + TransformedVolume, ) from celluloid import Camera from IPython.core.display import HTML @@ -33,11 +33,6 @@ log = logging.getLogger(__name__) -VOL_NAME_TO_OPTIMAL_ACTION = { - "1": ManipulatorAction(rotation=(19.3, 0.0), translation=(0.0, 140.0)), - "2": ManipulatorAction(rotation=(5, 0), translation=(0, 112)), -} - class LabelmapEnvTerminationCriterion(TerminationCriterion["LabelmapEnv"], ABC): def __init__( @@ -69,7 +64,7 @@ class LabelmapEnv(ModularEnv[LabelmapStateAction, np.ndarray, np.ndarray]): def __init__( self, - name2volume: dict[str, sitk.Image], + name2volume: dict[str, ImageVolume], observation: Observation[LabelmapStateAction, Any], reward_metric: RewardMetric[LabelmapStateAction] = LabelmapClusteringBasedReward(), termination_criterion: TerminationCriterion | None = LabelmapEnvTerminationCriterion(), @@ -98,7 +93,7 @@ def __init__( # set at reset self._cur_labelmap_name: str | None = None - self._cur_labelmap_volume: sitk.Image | None = None + self._cur_labelmap_volume: ImageVolume | TransformedVolume | None = None self._cur_optimal_action: ManipulatorAction | None = None self.user_defined_bounds = rotation_bounds, translation_bounds @@ -183,7 +178,7 @@ def cur_labelmap_name(self) -> str | None: return self._cur_labelmap_name @property - def cur_labelmap_volume(self) -> sitk.Image | None: + def cur_labelmap_volume(self) -> ImageVolume | TransformedVolume | None: return self._cur_labelmap_volume @property @@ -243,12 +238,13 @@ def get_manipulator_action_from_normalized_action( ) def _get_slice_from_action(self, action: np.ndarray | ManipulatorAction) -> np.ndarray: + if self.cur_labelmap_volume is None: + raise RuntimeError("The labelmap volume must not be None, did you call reset?") if isinstance(action, np.ndarray): manipulator_action = self.get_manipulator_action_from_normalized_action(action) else: manipulator_action = action - sliced_volume = get_volume_slice( - volume=self.cur_labelmap_volume, + sliced_volume = self.cur_labelmap_volume.get_volume_slice( slice_shape=self._slice_shape, action=manipulator_action, ) @@ -273,10 +269,9 @@ def compute_next_state( def apply_volume_transformation( self, - volume: sitk.Image, + volume: ImageVolume, volume_transformation_action: ManipulatorAction, - optimal_action: ManipulatorAction, - ) -> (sitk.Image, ManipulatorAction): # type: ignore + ) -> (TransformedVolume, ManipulatorAction): # type: ignore """Apply a random transformation to the volume and to the optimal action. The transformation is a random rotation and translation. The bounds of the rotation are updated if they have already been set. The translation bounds are computed from the volume size in the 'sample_initial_state' method. @@ -286,13 +281,11 @@ def apply_volume_transformation( :param optimal_action: the optimal action for the volume to transform accordingly :return: the transformed volume and the transformed optimal action """ - transformed_volume = create_transformed_volume( + transformed_volume = TransformedVolume.create_transformed_volume( volume=volume, transformation_action=volume_transformation_action, ) - transformed_optimal_action = transformed_volume.transform_action( - optimal_action, - ) + transformed_optimal_action = transformed_volume.optimal_action if self.rotation_bounds: bounds = list(self.rotation_bounds) bounds[0] += abs(volume_transformation_action.rotation[0]) @@ -310,18 +303,16 @@ def sample_initial_state(self) -> LabelmapStateAction: ) sampled_image_name = np.random.choice(list(self.name2volume.keys())) self._cur_labelmap_name = sampled_image_name - volume_optimal_action = deepcopy(VOL_NAME_TO_OPTIMAL_ACTION[sampled_image_name]) if self._apply_volume_transformation: volume_transformation_action = ManipulatorAction.sample() self._cur_labelmap_volume, self._cur_optimal_action = self.apply_volume_transformation( volume=self.name2volume[sampled_image_name], volume_transformation_action=volume_transformation_action, - optimal_action=volume_optimal_action, ) else: self._cur_labelmap_volume = self.name2volume[sampled_image_name] - self._cur_optimal_action = volume_optimal_action + self._cur_optimal_action = self._cur_labelmap_volume.optimal_action if None in self.translation_bounds: self._compute_translation_bounds() if self._slice_shape is None: @@ -337,7 +328,7 @@ def sample_initial_state(self) -> LabelmapStateAction: optimal_labelmap=None, ) - def compute_slice_shape(self, volume: sitk.Image | None) -> None: + def compute_slice_shape(self, volume: ImageVolume | None) -> None: """Compute the shape of the 2D slices that will be used as observations.""" if volume is None: raise RuntimeError("The labelmap volume must not be None, did you call reset?") diff --git a/src/armscan_env/envs/rewards.py b/src/armscan_env/envs/rewards.py index b2866ab..9a4dc16 100644 --- a/src/armscan_env/envs/rewards.py +++ b/src/armscan_env/envs/rewards.py @@ -13,7 +13,7 @@ # ToDo: make a cache for the function def anatomy_based_rwd( tissue_clusters: TissueClusters, - n_landmarks: Sequence[int] = (4, 2, 1), + n_landmarks: Sequence[int] = (4, 3, 1), ) -> float: """Calculate the reward based on the presence and location of anatomical landmarks. diff --git a/src/armscan_env/envs/state_action.py b/src/armscan_env/envs/state_action.py index e62afc2..c672079 100644 --- a/src/armscan_env/envs/state_action.py +++ b/src/armscan_env/envs/state_action.py @@ -30,12 +30,7 @@ def to_normalized_array( # normalize translation to [-1, 1]: 0 -> -1, translation_bounds -> 1 rotation = np.zeros(2) translation = np.zeros(2) - if self.translation[0] < 0 or self.translation[1] < 0: - log.debug( - "Action contains a negative translation, out of bounds.\n" - "Projecting the origin of the viewing plane to positive octant.", - ) - self.project_to_positive() + for i in range(2): if rotation_bounds[i] == 0.0: rotation[i] = 0.0 @@ -83,31 +78,6 @@ def from_normalized_array( return cls(rotation=tuple(rotation), translation=tuple(translation)) # type: ignore - def project_to_positive(self) -> None: - """Project the action to the positive octant. - This is needed when transforming the optimal action accordingly to the random volume transformation. - It might be, that for a negative translation and/or a negative z-rotation, the coordinates defining the - optimal action land in negative space. Since the action defines a coordinate frame which infers a plane - (x-z plane, y normal to the plane), assuming that this plane is still intercepting the positive octant, - it is possible to redefine the action in positive coordinates by projecting it into the positive octant. - - It needs to be tested, that the volume transformations keep the optimal action in a reachable space. - Volume transformations are used for data augmentation only, so can be defined in the most convenient way. - """ - tx, ty = self.translation - thz, thx = self.rotation - log.debug(f"Translation before projection: {self.translation}") - while tx < 0 or ty < 0: - if tx < 0: - ty = (np.tan(np.deg2rad(thz)) * (-tx)) + ty - tx = 0 - if ty < 0: - tx = ((1 / np.tan(np.deg2rad(thz))) * (-ty)) + tx - ty = 0 - translation = (tx, ty) - log.debug(f"Translation after projection: {translation}") - self.translation = translation - @classmethod def sample( cls, diff --git a/src/armscan_env/volumes/loading.py b/src/armscan_env/volumes/loading.py index e212ff3..1520202 100644 --- a/src/armscan_env/volumes/loading.py +++ b/src/armscan_env/volumes/loading.py @@ -1,13 +1,64 @@ +from enum import Enum + import numpy as np import SimpleITK as sitk from armscan_env.config import get_config +from armscan_env.envs.state_action import ManipulatorAction +from armscan_env.volumes.volumes import ImageVolume config = get_config() -def resize_sitk_volume( - volumes: list[sitk.Image], # n_spacing: tuple[float, float, float], -) -> list[sitk.Image]: +class RegisteredLabelmap(Enum): + v1 = 1 + v2 = 2 + v13 = 13 + v17 = 17 + v18 = 18 + v35 = 35 + v42 = 42 + + def get_optimal_action(self) -> ManipulatorAction: + match self: + case RegisteredLabelmap.v1: + return ManipulatorAction(rotation=(19.3, 0.0), translation=(0.0, 140.0)) + case RegisteredLabelmap.v2: + return ManipulatorAction(rotation=(5, 0), translation=(0, 112)) + case RegisteredLabelmap.v13: + return ManipulatorAction(rotation=(5, 0), translation=(0, 165)) + case RegisteredLabelmap.v17: + return ManipulatorAction(rotation=(5, 0), translation=(0, 158)) + case RegisteredLabelmap.v18: + return ManipulatorAction(rotation=(0, 0), translation=(0, 105)) + case RegisteredLabelmap.v35: + return ManipulatorAction(rotation=(3, 0), translation=(0, 155)) + case RegisteredLabelmap.v42: + return ManipulatorAction(rotation=(-3, 0), translation=(0, 178)) + case _: + raise ValueError(f"Optimal action for {self} not defined") + + def get_labelmap_id(self) -> int: + return self.value + + def get_file_path(self) -> str: + return config.get_single_labelmap_path(self.get_labelmap_id()) + + def load_labelmap(self) -> ImageVolume: + volume = sitk.ReadImage(self.get_file_path()) + optimal_action = self.get_optimal_action() + return ImageVolume(volume, optimal_action=optimal_action) + + @classmethod + def load_all_labelmaps(cls, normalize_spacing: bool = True) -> list[ImageVolume]: + volumes = [labelmap.load_labelmap() for labelmap in cls] + if normalize_spacing: + volumes = normalize_sitk_volumes_to_highest_spacing(volumes) + return volumes + + +def normalize_sitk_volumes_to_highest_spacing( + volumes: list[ImageVolume], # n_spacing: tuple[float, float, float], +) -> list[ImageVolume]: """Resize a SimpleITK volume to a normalized spacing, and interpolate to get right amount of voxels. Have a look at [this](https://stackoverflow.com/questions/48065117/simpleitk-resize-images) link to see potential problems. @@ -39,26 +90,21 @@ def resize_sitk_volume( resampler.SetOutputDirection(volume.GetDirection()) resampler.SetOutputOrigin(volume.GetOrigin()) resampler.SetInterpolator(sitk.sitkNearestNeighbor) - normalized_volumes.append(resampler.Execute(volume)) + normalized_volume = ImageVolume( + resampler.Execute(volume), + optimal_action=volume.optimal_action, + ) + normalized_volumes.append(normalized_volume) return normalized_volumes def load_sitk_volumes( normalize: bool = False, -) -> list[sitk.Image]: +) -> list[ImageVolume]: """Load a SimpleITK volume from a file. :param normalize: whether to normalize the volumes to a single spacing :return: the loaded volume """ - volumes = [] - # count how many nii files are under the path and load them with config.get_labels_patt - for label in range(1, config.count_labels() + 1): - volume = sitk.ReadImage(config.get_labels_path(label)) - volumes.append(volume) - - if normalize: - volumes = resize_sitk_volume(volumes) - - return volumes + return RegisteredLabelmap.load_all_labelmaps(normalize_spacing=normalize) diff --git a/src/armscan_env/volumes/slicing.py b/src/armscan_env/volumes/slicing.py deleted file mode 100644 index ccdff51..0000000 --- a/src/armscan_env/volumes/slicing.py +++ /dev/null @@ -1,195 +0,0 @@ -import logging -from typing import Any - -import numpy as np -import SimpleITK as sitk -from armscan_env.envs.state_action import ManipulatorAction - -log = logging.getLogger(__name__) - - -class EulerTransform: - def __init__(self, action: ManipulatorAction, origin: np.ndarray | None = None): - if origin is None: - origin = np.zeros(3) - self.action = action - self.origin = origin - - def get_transform_matrix(self) -> np.ndarray: - # Euler's transformation - # Rotation is defined by three rotations around z1, x2, z2 axis - th_z1 = np.deg2rad(self.action.rotation[0]) - th_x2 = np.deg2rad(self.action.rotation[1]) - - # transformation simplified at z2=0 since this rotation is never performed - return np.array( - [ - [ - np.cos(th_z1), - -np.sin(th_z1) * np.cos(th_x2), - np.sin(th_z1) * np.sin(th_x2), - self.origin[0] + self.action.translation[0], - ], - [ - np.sin(th_z1), - np.cos(th_z1) * np.cos(th_x2), - -np.cos(th_z1) * np.sin(th_x2), - self.origin[1] + self.action.translation[1], - ], - [0, np.sin(th_x2), np.cos(th_x2), self.origin[2]], - [0, 0, 0, 1], - ], - ) - - @staticmethod - def get_angles_from_rotation_matrix(rotation_matrix: np.ndarray) -> np.ndarray: - """Get the angles from a rotation matrix.""" - # Extract the angles from the rotation matrix - th_x2 = np.arcsin(rotation_matrix[2, 1]) - th_z1 = np.arcsin(rotation_matrix[1, 0]) - - # Convert the angles to degrees - th_z1 = np.rad2deg(th_z1) - th_x2 = np.rad2deg(th_x2) - - return np.array([th_z1, th_x2]) - - -class TransformedVolume(sitk.Image): - """Represents a volume that has been transformed by an action. - - Should only ever be instantiated by `create_transformed_volume`. - """ - - def __init__(self, *args: Any, transformation_action: ManipulatorAction | None, _private: int): - if _private != 42: - raise ValueError( - "TransformedVolume should only be instantiated by create_transformed_volume.", - ) - if transformation_action is None: - transformation_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) - super().__init__(*args) - self._transformation_action = transformation_action - - @property - def transformation_action(self) -> ManipulatorAction: - return self._transformation_action - - def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: - """Transform an action by the inverse of the volume transformation to be relative to the new coordinate - system. - """ - origin = np.array(self.GetOrigin()) - - volume_rotation = np.deg2rad(self.transformation_action.rotation) - volume_translation = self.transformation_action.translation - volume_transform = sitk.Euler3DTransform( - origin, - volume_rotation[1], - 0, - volume_rotation[0], - (*volume_translation, 0), - ) - - inverse_volume_transform = volume_transform.GetInverse() - inverse_volume_transform_matrix = np.eye(4) - inverse_volume_transform_matrix[:3, :3] = np.array( - inverse_volume_transform.GetMatrix(), - ).reshape(3, 3) - inverse_volume_transform_matrix[:3, 3] = inverse_volume_transform.GetTranslation() - - action_rotation = np.deg2rad(relative_action.rotation) - action_translation = relative_action.translation - action_transform = sitk.Euler3DTransform( - origin, - action_rotation[1], - 0, - action_rotation[0], - (*action_translation, 0), - ) - - action_transform_matrix = np.eye(4) - action_transform_matrix[:3, :3] = np.array(action_transform.GetMatrix()).reshape(3, 3) - action_transform_matrix[:3, 3] = action_transform.GetTranslation() - - # 1_A_s = 1_T_0 * 0_A_s - new_action_matrix = np.dot(inverse_volume_transform_matrix, action_transform_matrix) - transformed_action = ManipulatorAction( - rotation=EulerTransform.get_angles_from_rotation_matrix(new_action_matrix[:3, :3]), - translation=new_action_matrix[:2, 3], - ) - - log.debug( - f"Random transformation: {self.transformation_action}\n" - f"Original action: {relative_action}\n" - f"Transformed action: {transformed_action}\n", - ) - - return transformed_action - - -def create_transformed_volume( - volume: sitk.Image, - transformation_action: ManipulatorAction, -) -> TransformedVolume: - """Transform a 3D volume with arbitrary rotation and translation. - - :param volume: 3D volume to be transformed - :param transformation_action: action to transform the volume - :return: the sliced volume. - """ - if isinstance(volume, TransformedVolume): - raise ValueError( - f"This operation should only be performed on a non-transformed volume " - f"but got an instance of: {volume.__class__.__name__}.", - ) - - origin = np.array(volume.GetOrigin()) - rotation = np.deg2rad(transformation_action.rotation) - translation = transformation_action.translation - - transform = sitk.Euler3DTransform() - transform.SetRotation(rotation[1], 0, rotation[0]) - transform.SetTranslation((*translation, 0)) - transform.SetCenter(origin) - resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID()) - # needed to deal with rotation dependency of the volume - return TransformedVolume( - resampled, - transformation_action=transformation_action, - _private=42, - ) - - -def get_volume_slice( - volume: sitk.Image, - action: ManipulatorAction, - slice_shape: tuple[int, int] | None = None, -) -> sitk.Image: - """Slice a 3D volume with arbitrary rotation and translation. - - :param volume: 3D volume to be sliced - :param action: action to transform the volume - :param slice_shape: shape of the output slice - :return: the sliced volume. - """ - if slice_shape is None: - slice_shape = (volume.GetSize()[0], volume.GetSize()[2]) - - origin = np.array(volume.GetOrigin()) - rotation = np.deg2rad(action.rotation) - translation = action.translation - - transform = sitk.Euler3DTransform() - transform.SetRotation(rotation[1], 0, rotation[0]) - transform.SetTranslation((*translation, 0)) - transform.SetCenter(origin) - resampled = sitk.Resample(volume, transform, sitk.sitkNearestNeighbor, 0.0, volume.GetPixelID()) - - resampler = sitk.ResampleImageFilter() - resampler.SetReferenceImage(resampled) - resampler.SetSize((slice_shape[0], 2, slice_shape[1])) - resampler.SetInterpolator(sitk.sitkNearestNeighbor) - - # Resample the volume on the arbitrary plane - return resampler.Execute(resampled)[:, 0, :] diff --git a/src/armscan_env/volumes/volumes.py b/src/armscan_env/volumes/volumes.py new file mode 100644 index 0000000..7ce70a3 --- /dev/null +++ b/src/armscan_env/volumes/volumes.py @@ -0,0 +1,285 @@ +import logging +from typing import Any, Self + +import numpy as np +import SimpleITK as sitk +from armscan_env.envs.state_action import ManipulatorAction + +log = logging.getLogger(__name__) + + +class EulerTransform: + def __init__(self, action: ManipulatorAction, origin: np.ndarray | None = None): + if origin is None: + origin = np.zeros(3) + self.action = action + self.origin = origin + + def get_transform_matrix(self) -> np.ndarray: + # Euler's transformation + # Rotation is defined by three rotations around z1, x2, z2 axis + th_z1 = np.deg2rad(self.action.rotation[0]) + th_x2 = np.deg2rad(self.action.rotation[1]) + + # transformation simplified at z2=0 since this rotation is never performed + return np.array( + [ + [ + np.cos(th_z1), + -np.sin(th_z1) * np.cos(th_x2), + np.sin(th_z1) * np.sin(th_x2), + self.origin[0] + self.action.translation[0], + ], + [ + np.sin(th_z1), + np.cos(th_z1) * np.cos(th_x2), + -np.cos(th_z1) * np.sin(th_x2), + self.origin[1] + self.action.translation[1], + ], + [0, np.sin(th_x2), np.cos(th_x2), self.origin[2]], + [0, 0, 0, 1], + ], + ) + + @staticmethod + def get_angles_from_rotation_matrix(rotation_matrix: np.ndarray) -> np.ndarray: + """Get the angles from a rotation matrix.""" + # Extract the angles from the rotation matrix + th_x2 = np.arcsin(rotation_matrix[2, 1]) + th_z1 = np.arcsin(rotation_matrix[1, 0]) + + # Convert the angles to degrees + th_z1 = np.rad2deg(th_z1) + th_x2 = np.rad2deg(th_x2) + + return np.array([th_z1, th_x2]) + + +class ImageVolume(sitk.Image): + """Represents a 3D volume.""" + + def __init__( + self, + volume: sitk.Image, + *args: Any, + optimal_action: ManipulatorAction | None = None, + ): + super().__init__(volume, *args) + if optimal_action is None: + optimal_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) + self._optimal_action = optimal_action + + @property + def optimal_action(self) -> ManipulatorAction: + return self._optimal_action + + def get_volume_slice( + self, + action: ManipulatorAction, + slice_shape: tuple[int, int] | None = None, + ) -> sitk.Image: + """Slice a 3D volume with arbitrary rotation and translation. + + :param volume: 3D volume to be sliced + :param action: action to transform the volume + :param slice_shape: shape of the output slice + :return: the sliced volume. + """ + if slice_shape is None: + slice_shape = (self.GetSize()[0], self.GetSize()[2]) + + origin = np.array(self.GetOrigin()) + rotation = np.deg2rad(action.rotation) + translation = action.translation + + transform = sitk.Euler3DTransform() + transform.SetRotation(rotation[1], 0, rotation[0]) + transform.SetTranslation((*translation, 0)) + transform.SetCenter(origin) + slice_volume = sitk.Resample( + self, + transform, + sitk.sitkNearestNeighbor, + 0.0, + self.GetPixelID(), + ) + + resampler = sitk.ResampleImageFilter() + resampler.SetReferenceImage(slice_volume) + resampler.SetSize((slice_shape[0], 2, slice_shape[1])) + resampler.SetInterpolator(sitk.sitkNearestNeighbor) + + # Resample the volume on the arbitrary plane + return resampler.Execute(slice_volume)[:, 0, :] + + +class TransformedVolume(ImageVolume): + """Represents a volume that has been transformed by an action. + + Should only ever be instantiated by `create_transformed_volume`. + """ + + def __init__( + self, + volume: ImageVolume, + transformation_action: ManipulatorAction | None, + *args: Any, + _private: int, + ): + if _private != 42: + raise ValueError( + "TransformedVolume should only be instantiated by create_transformed_volume.", + ) + super().__init__(volume, *args) + if transformation_action is None: + transformation_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) + self._transformation_action = transformation_action + self._tr_optimal_action = self.transform_action(volume.optimal_action) + + @property + def transformation_action(self) -> ManipulatorAction: + return self._transformation_action + + @property + def optimal_action(self) -> ManipulatorAction: + return self._tr_optimal_action + + @classmethod + def create_transformed_volume( + cls, + volume: ImageVolume, + transformation_action: ManipulatorAction, + ) -> Self: + """Transform a 3D volume with arbitrary rotation and translation. + + :param volume: 3D volume to be transformed + :param transformation_action: action to transform the volume + :return: the sliced volume. + """ + if isinstance(volume, TransformedVolume): + raise ValueError( + f"This operation should only be performed on a non-transformed volume " + f"but got an instance of: {volume.__class__.__name__}.", + ) + + origin = np.array(volume.GetOrigin()) + rotation = np.deg2rad(transformation_action.rotation) + translation = transformation_action.translation + + transform = sitk.Euler3DTransform() + transform.SetRotation(rotation[1], 0, rotation[0]) + transform.SetTranslation((*translation, 0)) + transform.SetCenter(origin) + resampled = sitk.Resample( + volume, + transform, + sitk.sitkNearestNeighbor, + 0.0, + volume.GetPixelID(), + ) + resampled = ImageVolume(resampled, optimal_action=volume.optimal_action) + # needed to deal with rotation dependency of the volume + return cls( + resampled, + transformation_action=transformation_action, + _private=42, + ) + + def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: + """Transform an action by the inverse of the volume transformation to be relative to the new coordinate + system. + """ + origin = np.array(self.GetOrigin()) + + volume_rotation = np.deg2rad(self.transformation_action.rotation) + volume_translation = self.transformation_action.translation + volume_transform = sitk.Euler3DTransform( + origin, + volume_rotation[1], + 0, + volume_rotation[0], + (*volume_translation, 0), + ) + + inverse_volume_transform = volume_transform.GetInverse() + inverse_volume_transform_matrix = np.eye(4) + inverse_volume_transform_matrix[:3, :3] = np.array( + inverse_volume_transform.GetMatrix(), + ).reshape(3, 3) + inverse_volume_transform_matrix[:3, 3] = inverse_volume_transform.GetTranslation() + + action_rotation = np.deg2rad(relative_action.rotation) + action_translation = relative_action.translation + action_transform = sitk.Euler3DTransform( + origin, + action_rotation[1], + 0, + action_rotation[0], + (*action_translation, 0), + ) + + action_transform_matrix = np.eye(4) + action_transform_matrix[:3, :3] = np.array(action_transform.GetMatrix()).reshape(3, 3) + action_transform_matrix[:3, 3] = action_transform.GetTranslation() + + # 1_A_s = 1_T_0 * 0_A_s + new_action_matrix = np.dot(inverse_volume_transform_matrix, action_transform_matrix) + transformed_action = ManipulatorAction( + rotation=EulerTransform.get_angles_from_rotation_matrix(new_action_matrix[:3, :3]), + translation=new_action_matrix[:2, 3], + ) + + log.debug( + f"Random transformation: {self.transformation_action}\n" + f"Original action: {relative_action}\n" + f"Transformed action: {transformed_action}\n", + ) + + if ( + any(transformed_action.translation < 0) + or transformed_action.translation[0] > self.GetSize()[0] + or transformed_action.translation[1] > self.GetSize()[1] + ): + log.debug( + "Action contains a translation out of volume bounds.\n" + "Projecting the origin of the viewing plane into the bounds.", + ) + transformed_action = self.project_into_volume_bounds(transformed_action) + + return transformed_action + + def project_into_volume_bounds( + self, + transformed_action: ManipulatorAction, + ) -> ManipulatorAction: + """Project the action to the positive octant. + This is needed when transforming the optimal action accordingly to the random volume transformation. + It might be, that for a negative translation and/or a negative z-rotation, the coordinates defining the + optimal action land in negative space. Since the action defines a coordinate frame which infers a plane + (x-z plane, y normal to the plane), assuming that this plane is still intercepting the positive octant, + it is possible to redefine the action in positive coordinates by projecting it into the positive octant. + + It needs to be tested, that the volume transformations keep the optimal action in a reachable space. + Volume transformations are used for data augmentation only, so can be defined in the most convenient way. + """ + sx, sy = self.GetSize()[0], self.GetSize()[1] + tx, ty = transformed_action.translation + thz, thx = transformed_action.rotation + log.debug(f"Translation before projection: {transformed_action.translation}") + while tx < 0 or ty < 0 or tx > sx or ty > sy: + if tx < 0: + ty = (np.tan(np.deg2rad(thz)) * (-tx)) + ty + tx = 0 + if ty < 0: + tx = ((1 / np.tan(np.deg2rad(thz))) * (-ty)) + tx + ty = 0 + if tx > sx: + ty = (np.tan(np.deg2rad(thz)) * (sx - tx)) + ty + tx = sx + if ty > sy: + tx = ((1 / np.tan(np.deg2rad(thz))) * (sy - ty)) + tx + ty = sy + translation = (tx, ty) + log.debug(f"Translation after projection: {translation}") + transformed_action.translation = translation + return transformed_action diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index a1aa9b9..b998f1f 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -1,13 +1,14 @@ +import matplotlib.pyplot as plt import numpy as np import pytest import SimpleITK as sitk from armscan_env.clustering import TissueClusters, TissueLabel from armscan_env.config import get_config -from armscan_env.envs.labelmaps_navigation import VOL_NAME_TO_OPTIMAL_ACTION from armscan_env.envs.rewards import anatomy_based_rwd from armscan_env.envs.state_action import ManipulatorAction +from armscan_env.util.visualizations import show_clusters from armscan_env.volumes.loading import load_sitk_volumes -from armscan_env.volumes.slicing import create_transformed_volume, get_volume_slice +from armscan_env.volumes.volumes import TransformedVolume config = get_config() @@ -15,6 +16,7 @@ @pytest.fixture(scope="session") def labelmaps(): result = load_sitk_volumes(normalize=False) + result.extend(load_sitk_volumes(normalize=True)) if not result: raise ValueError("No labelmaps files found in the labels directory") return result @@ -37,8 +39,7 @@ def test_all_tissue_labels_present(labelmaps): def test_labelmap_properly_sliced(labelmaps): for labelmap in labelmaps: slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) - sliced_volume = get_volume_slice( - volume=labelmap, + sliced_volume = labelmap.get_volume_slice( slice_shape=slice_shape, action=ManipulatorAction( rotation=(0.0, 0.0), @@ -50,13 +51,11 @@ def test_labelmap_properly_sliced(labelmaps): @staticmethod def test_optimal_actions(labelmaps): - for i, labelmap in enumerate(labelmaps): - optimal_action = VOL_NAME_TO_OPTIMAL_ACTION[str(i + 1)] + for _i, labelmap in enumerate(labelmaps): slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) - sliced_volume = get_volume_slice( - volume=labelmap, + sliced_volume = labelmap.get_volume_slice( slice_shape=slice_shape, - action=optimal_action, + action=labelmap.optimal_action, ) sliced_img = sitk.GetArrayFromImage(sliced_volume) cluster = TissueClusters.from_labelmap_slice(sliced_img.T) @@ -66,24 +65,27 @@ def test_optimal_actions(labelmaps): @staticmethod def test_rand_transformations(labelmaps): for i, labelmap in enumerate(labelmaps): - optimal_action = VOL_NAME_TO_OPTIMAL_ACTION[str(i + 1)] slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) j = 0 - while j < 10: + while j < 3: volume_transformation_action = ManipulatorAction.sample() - transformed_labelmap = create_transformed_volume( + transformed_labelmap = TransformedVolume.create_transformed_volume( volume=labelmap, transformation_action=volume_transformation_action, ) - transformed_optimal_action = transformed_labelmap.transform_action(optimal_action) - sliced_volume = get_volume_slice( - volume=transformed_labelmap, + sliced_volume = transformed_labelmap.get_volume_slice( slice_shape=slice_shape, - action=transformed_optimal_action, + action=transformed_labelmap.optimal_action, ) sliced_img = sitk.GetArrayFromImage(sliced_volume) cluster = TissueClusters.from_labelmap_slice(sliced_img.T) reward = anatomy_based_rwd(cluster) + if reward < -0.1: + show_clusters(cluster, sliced_img.T) + print( + f"Volume {i + 1} and transformation {volume_transformation_action}, reward: {reward}", + ) + plt.show() j += 1 assert ( reward > -0.1 From a2070a52b32134adc14f360e2e127d26850059b9 Mon Sep 17 00:00:00 2001 From: carlocagnetta Date: Fri, 19 Jul 2024 14:19:46 +0200 Subject: [PATCH 36/36] Fixed clustering, restricted termination, added volume --- docs/02_notebooks/L4_environment.ipynb | 39 ++++++++++++ docs/02_notebooks/L5_linear_sweep.ipynb | 40 ------------ notebooks/normalized_volumes.ipynb | 16 ++--- notebooks/random_volume_transformations.ipynb | 14 ++--- src/armscan_env/clustering.py | 4 +- src/armscan_env/envs/labelmaps_navigation.py | 4 +- src/armscan_env/envs/rewards.py | 2 +- src/armscan_env/volumes/loading.py | 5 +- src/armscan_env/volumes/volumes.py | 61 ++++++++----------- test/armscan_env/test_labelmap_volumes.py | 22 ++++--- 10 files changed, 102 insertions(+), 105 deletions(-) diff --git a/docs/02_notebooks/L4_environment.ipynb b/docs/02_notebooks/L4_environment.ipynb index b17d859..841fa9f 100644 --- a/docs/02_notebooks/L4_environment.ipynb +++ b/docs/02_notebooks/L4_environment.ipynb @@ -42,6 +42,7 @@ " LabelmapEnvTerminationCriterion,\n", ")\n", "from armscan_env.envs.observations import (\n", + " ActionRewardObservation,\n", " LabelmapSliceAsChannelsObservation,\n", ")\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", @@ -215,6 +216,44 @@ "HTML(animation.to_jshtml())" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "volume_size = volumes[0].GetSize()\n", + "\n", + "env = LabelmapEnv(\n", + " name2volume={\"1\": volumes[6]},\n", + " observation=ActionRewardObservation(action_shape=(2,)).to_array_observation(),\n", + " slice_shape=(volume_size[0], volume_size[2]),\n", + " reward_metric=LabelmapClusteringBasedReward(),\n", + " termination_criterion=LabelmapEnvTerminationCriterion(min_reward_threshold=-0.05),\n", + " max_episode_len=10,\n", + " rotation_bounds=(30.0, 10.0),\n", + " translation_bounds=(None, None),\n", + " render_mode=\"animation\",\n", + " project_actions_to=\"zy\",\n", + " apply_volume_transformation=True,\n", + ")\n", + "\n", + "observation, info = env.reset()\n", + "for _ in range(50):\n", + " action = env.action_space.sample()\n", + " epsilon = 0.1\n", + " if np.random.rand() > epsilon:\n", + " observation, reward, terminated, truncated, info = env.step(action)\n", + " else:\n", + " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", + " env.render()\n", + "\n", + " if terminated or truncated:\n", + " observation, info = env.reset(reset_render=False)\n", + "animation = env.get_cur_animation()\n", + "env.get_cur_animation_as_html()" + ] + }, { "cell_type": "code", "execution_count": null, diff --git a/docs/02_notebooks/L5_linear_sweep.ipynb b/docs/02_notebooks/L5_linear_sweep.ipynb index b6e94e2..860c9aa 100644 --- a/docs/02_notebooks/L5_linear_sweep.ipynb +++ b/docs/02_notebooks/L5_linear_sweep.ipynb @@ -247,46 +247,6 @@ "projected_env.get_cur_animation_as_html()" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "37f3d415b00f01a2", - "metadata": {}, - "outputs": [], - "source": [ - "volume_size = volumes[0].GetSize()\n", - "\n", - "env = LabelmapEnv(\n", - " name2volume={\"1\": volumes[0]},\n", - " observation=ActionRewardObservation(action_shape=(2,)).to_array_observation(),\n", - " slice_shape=(volume_size[0], volume_size[2]),\n", - " reward_metric=LabelmapClusteringBasedReward(),\n", - " termination_criterion=LabelmapEnvTerminationCriterion(),\n", - " max_episode_len=10,\n", - " rotation_bounds=(30.0, 10.0),\n", - " translation_bounds=(None, None),\n", - " render_mode=\"animation\",\n", - " project_actions_to=\"zy\",\n", - " apply_volume_transformation=True,\n", - ")\n", - "\n", - "observation, info = env.reset()\n", - "for _ in range(50):\n", - " action = env.action_space.sample()\n", - " epsilon = 0.1\n", - " if np.random.rand() > epsilon:\n", - " observation, reward, terminated, truncated, info = env.step(action)\n", - " else:\n", - " print(f\"taken optimal action: {env.get_optimal_action()}\")\n", - " observation, reward, terminated, truncated, info = env.step_to_optimal_state()\n", - " env.render()\n", - "\n", - " if terminated or truncated:\n", - " observation, info = env.reset(reset_render=False)\n", - "animation = env.get_cur_animation()\n", - "env.get_cur_animation_as_html()" - ] - }, { "cell_type": "code", "execution_count": null, diff --git a/notebooks/normalized_volumes.ipynb b/notebooks/normalized_volumes.ipynb index 82ad96b..bdb3ee5 100644 --- a/notebooks/normalized_volumes.ipynb +++ b/notebooks/normalized_volumes.ipynb @@ -14,7 +14,10 @@ "import numpy as np\n", "import SimpleITK as sitk\n", "from armscan_env import config\n", - "from armscan_env.volumes.loading import load_sitk_volumes, resize_sitk_volume\n", + "from armscan_env.volumes.loading import (\n", + " load_sitk_volumes,\n", + " normalize_sitk_volumes_to_highest_spacing,\n", + ")\n", "\n", "config = config.get_config()" ] @@ -36,7 +39,7 @@ "metadata": {}, "outputs": [], "source": [ - "normalized_volumes = resize_sitk_volume(volumes)" + "normalized_volumes = normalize_sitk_volumes_to_highest_spacing(volumes)" ] }, { @@ -46,7 +49,7 @@ "metadata": {}, "outputs": [], "source": [ - "print(volumes[5].GetSize())\n", + "print(volumes[1].GetSize())\n", "print(normalized_volumes[1].GetSize())" ] }, @@ -71,17 +74,16 @@ "source": [ "from armscan_env.clustering import TissueClusters\n", "from armscan_env.envs.rewards import anatomy_based_rwd\n", - "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", "\n", - "action = ManipulatorAction(rotation=(-3, 0), translation=(0, 178))\n", + "action = volume.optimal_action\n", "x_size_2, y_size_2, z_size_2 = (\n", " sz * sp for sz, sp in zip(volume.GetSize(), volume.GetSpacing(), strict=True)\n", ")\n", "extent_xy_2 = (0, x_size_2, y_size_2, 0)\n", "\n", "spacing = volume.GetSpacing()\n", - "plt.imshow(array[33, :, :], extent=extent_xy_2)\n", + "plt.imshow(array[40, :, :], extent=extent_xy_2)\n", "\n", "o = volume.GetOrigin()\n", "x_dash = np.arange(x_size_2)\n", @@ -106,7 +108,7 @@ ")\n", "sliced_img = sitk.GetArrayFromImage(sliced_volume)\n", "cluster = TissueClusters.from_labelmap_slice(sliced_img.T)\n", - "show_clusters(cluster, sliced_img.T, aspect=4)\n", + "show_clusters(cluster, sliced_img.T)\n", "plt.show()" ] }, diff --git a/notebooks/random_volume_transformations.ipynb b/notebooks/random_volume_transformations.ipynb index a63baf9..d0ca358 100644 --- a/notebooks/random_volume_transformations.ipynb +++ b/notebooks/random_volume_transformations.ipynb @@ -26,11 +26,9 @@ "from armscan_env.envs.rewards import anatomy_based_rwd\n", "from armscan_env.envs.state_action import ManipulatorAction\n", "from armscan_env.util.visualizations import show_clusters\n", - "from armscan_env.volumes.loading import load_sitk_volumes\n", - "from armscan_env.volumes.volumes import TransformedVolume\n", + "from armscan_env.volumes.volumes import ImageVolume, TransformedVolume\n", "\n", - "config = config.get_config()\n", - "volumes = load_sitk_volumes(normalize=True)" + "config = config.get_config()" ] }, { @@ -40,7 +38,9 @@ "metadata": {}, "outputs": [], "source": [ - "volume = volumes[4]\n", + "sitk_volume = sitk.ReadImage(config.get_single_labelmap_path(2))\n", + "optimal_action = ManipulatorAction(rotation=(0, 0), translation=(0, 117))\n", + "volume = ImageVolume(sitk_volume, optimal_action=optimal_action)\n", "volume_img = sitk.GetArrayFromImage(volume)\n", "\n", "x_size, y_size, z_size = (\n", @@ -48,7 +48,7 @@ ")\n", "extent_xy = (0, x_size, y_size, 0)\n", "\n", - "plt.imshow(volume_img[40, :, :], extent=extent_xy)\n", + "plt.imshow(volume_img[47, :, :], extent=extent_xy)\n", "\n", "o = volume.GetOrigin()\n", "x_dash = np.arange(x_size)\n", @@ -92,7 +92,7 @@ " rotation=(-7.213170270886784, 0.0),\n", " translation=(-7.31243280019082, 9.172539411055304),\n", ")\n", - "transformed_volume = TransformedVolume.create_transformed_volume(volume, volume_transformation)\n", + "transformed_volume = TransformedVolume(volume, volume_transformation)\n", "transformed_action = transformed_volume.optimal_action\n", "print(f\"{volume.optimal_action=}\\n{transformed_volume.optimal_action=}\\n\")" ] diff --git a/src/armscan_env/clustering.py b/src/armscan_env/clustering.py index 63a5151..7067778 100644 --- a/src/armscan_env/clustering.py +++ b/src/armscan_env/clustering.py @@ -23,9 +23,9 @@ def find_DBSCAN_clusters(self, labelmap_slice: np.ndarray) -> list["DataCluster" case TissueLabel.BONES: return find_DBSCAN_clusters(self, labelmap_slice, eps=4.1, min_samples=46) case TissueLabel.TENDONS: - return find_DBSCAN_clusters(self, labelmap_slice, eps=2.5, min_samples=15) + return find_DBSCAN_clusters(self, labelmap_slice, eps=3, min_samples=18) case TissueLabel.ULNAR: - return find_DBSCAN_clusters(self, labelmap_slice, eps=2.0, min_samples=10) + return find_DBSCAN_clusters(self, labelmap_slice, eps=1.1, min_samples=4) case _: raise ValueError(f"Unknown tissue label: {self}") diff --git a/src/armscan_env/envs/labelmaps_navigation.py b/src/armscan_env/envs/labelmaps_navigation.py index e81187a..1a7340d 100644 --- a/src/armscan_env/envs/labelmaps_navigation.py +++ b/src/armscan_env/envs/labelmaps_navigation.py @@ -37,7 +37,7 @@ class LabelmapEnvTerminationCriterion(TerminationCriterion["LabelmapEnv"], ABC): def __init__( self, - min_reward_threshold: float = -0.1, + min_reward_threshold: float = -0.05, ): self.min_reward_threshold = min_reward_threshold @@ -281,7 +281,7 @@ def apply_volume_transformation( :param optimal_action: the optimal action for the volume to transform accordingly :return: the transformed volume and the transformed optimal action """ - transformed_volume = TransformedVolume.create_transformed_volume( + transformed_volume = TransformedVolume( volume=volume, transformation_action=volume_transformation_action, ) diff --git a/src/armscan_env/envs/rewards.py b/src/armscan_env/envs/rewards.py index 9a4dc16..ec97c22 100644 --- a/src/armscan_env/envs/rewards.py +++ b/src/armscan_env/envs/rewards.py @@ -93,7 +93,7 @@ class LabelmapClusteringBasedReward(RewardMetric[LabelmapStateAction]): def __init__( self, - n_landmarks: Sequence[int] = (4, 2, 1), + n_landmarks: Sequence[int] = (4, 3, 1), ): self.n_landmarks = n_landmarks diff --git a/src/armscan_env/volumes/loading.py b/src/armscan_env/volumes/loading.py index 1520202..5b5a9de 100644 --- a/src/armscan_env/volumes/loading.py +++ b/src/armscan_env/volumes/loading.py @@ -12,6 +12,7 @@ class RegisteredLabelmap(Enum): v1 = 1 v2 = 2 + v11 = 11 v13 = 13 v17 = 17 v18 = 18 @@ -24,6 +25,8 @@ def get_optimal_action(self) -> ManipulatorAction: return ManipulatorAction(rotation=(19.3, 0.0), translation=(0.0, 140.0)) case RegisteredLabelmap.v2: return ManipulatorAction(rotation=(5, 0), translation=(0, 112)) + case RegisteredLabelmap.v11: + return ManipulatorAction(rotation=(7, 0.0), translation=(0, 163)) case RegisteredLabelmap.v13: return ManipulatorAction(rotation=(5, 0), translation=(0, 165)) case RegisteredLabelmap.v17: @@ -100,7 +103,7 @@ def normalize_sitk_volumes_to_highest_spacing( def load_sitk_volumes( - normalize: bool = False, + normalize: bool = True, ) -> list[ImageVolume]: """Load a SimpleITK volume from a file. diff --git a/src/armscan_env/volumes/volumes.py b/src/armscan_env/volumes/volumes.py index 7ce70a3..9dade43 100644 --- a/src/armscan_env/volumes/volumes.py +++ b/src/armscan_env/volumes/volumes.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Self +from typing import Any import numpy as np import SimpleITK as sitk @@ -114,42 +114,14 @@ def get_volume_slice( class TransformedVolume(ImageVolume): - """Represents a volume that has been transformed by an action. - - Should only ever be instantiated by `create_transformed_volume`. - """ + """Represents a volume that has been transformed by an action.""" def __init__( self, volume: ImageVolume, transformation_action: ManipulatorAction | None, *args: Any, - _private: int, ): - if _private != 42: - raise ValueError( - "TransformedVolume should only be instantiated by create_transformed_volume.", - ) - super().__init__(volume, *args) - if transformation_action is None: - transformation_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) - self._transformation_action = transformation_action - self._tr_optimal_action = self.transform_action(volume.optimal_action) - - @property - def transformation_action(self) -> ManipulatorAction: - return self._transformation_action - - @property - def optimal_action(self) -> ManipulatorAction: - return self._tr_optimal_action - - @classmethod - def create_transformed_volume( - cls, - volume: ImageVolume, - transformation_action: ManipulatorAction, - ) -> Self: """Transform a 3D volume with arbitrary rotation and translation. :param volume: 3D volume to be transformed @@ -161,6 +133,8 @@ def create_transformed_volume( f"This operation should only be performed on a non-transformed volume " f"but got an instance of: {volume.__class__.__name__}.", ) + if transformation_action is None: + transformation_action = ManipulatorAction(rotation=(0.0, 0.0), translation=(0.0, 0.0)) origin = np.array(volume.GetOrigin()) rotation = np.deg2rad(transformation_action.rotation) @@ -178,12 +152,18 @@ def create_transformed_volume( volume.GetPixelID(), ) resampled = ImageVolume(resampled, optimal_action=volume.optimal_action) - # needed to deal with rotation dependency of the volume - return cls( - resampled, - transformation_action=transformation_action, - _private=42, - ) + + super().__init__(resampled, *args) + self._transformation_action = transformation_action + self._tr_optimal_action = self.transform_action(volume.optimal_action) + + @property + def transformation_action(self) -> ManipulatorAction: + return self._transformation_action + + @property + def optimal_action(self) -> ManipulatorAction: + return self._tr_optimal_action def transform_action(self, relative_action: ManipulatorAction) -> ManipulatorAction: """Transform an action by the inverse of the volume transformation to be relative to the new coordinate @@ -262,11 +242,15 @@ def project_into_volume_bounds( It needs to be tested, that the volume transformations keep the optimal action in a reachable space. Volume transformations are used for data augmentation only, so can be defined in the most convenient way. """ - sx, sy = self.GetSize()[0], self.GetSize()[1] + v_size = self.GetSize() + v_spacing = self.GetSpacing() + sx, sy = v_size[0] * v_spacing[0], v_size[1] * v_spacing[1] tx, ty = transformed_action.translation thz, thx = transformed_action.rotation log.debug(f"Translation before projection: {transformed_action.translation}") + while tx < 0 or ty < 0 or tx > sx or ty > sy: + prev_tx, prev_ty = tx, ty if tx < 0: ty = (np.tan(np.deg2rad(thz)) * (-tx)) + ty tx = 0 @@ -279,6 +263,9 @@ def project_into_volume_bounds( if ty > sy: tx = ((1 / np.tan(np.deg2rad(thz))) * (sy - ty)) + tx ty = sy + if tx == prev_tx and ty == prev_ty: + raise ValueError("Loop is stuck, reiterating through the same values.") + translation = (tx, ty) log.debug(f"Translation after projection: {translation}") transformed_action.translation = translation diff --git a/test/armscan_env/test_labelmap_volumes.py b/test/armscan_env/test_labelmap_volumes.py index b998f1f..4f4b886 100644 --- a/test/armscan_env/test_labelmap_volumes.py +++ b/test/armscan_env/test_labelmap_volumes.py @@ -51,7 +51,7 @@ def test_labelmap_properly_sliced(labelmaps): @staticmethod def test_optimal_actions(labelmaps): - for _i, labelmap in enumerate(labelmaps): + for i, labelmap in enumerate(labelmaps): slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) sliced_volume = labelmap.get_volume_slice( slice_shape=slice_shape, @@ -60,16 +60,22 @@ def test_optimal_actions(labelmaps): sliced_img = sitk.GetArrayFromImage(sliced_volume) cluster = TissueClusters.from_labelmap_slice(sliced_img.T) reward = anatomy_based_rwd(cluster) - assert reward > -0.1 + if reward < -0.05: + show_clusters(cluster, sliced_img.T) + print( + f"Volume {i + 1}, reward: {reward}", + ) + plt.show() + assert reward > -0.05 @staticmethod def test_rand_transformations(labelmaps): for i, labelmap in enumerate(labelmaps): slice_shape = (labelmap.GetSize()[0], labelmap.GetSize()[2]) j = 0 - while j < 3: + while j < 10: volume_transformation_action = ManipulatorAction.sample() - transformed_labelmap = TransformedVolume.create_transformed_volume( + transformed_labelmap = TransformedVolume( volume=labelmap, transformation_action=volume_transformation_action, ) @@ -80,13 +86,13 @@ def test_rand_transformations(labelmaps): sliced_img = sitk.GetArrayFromImage(sliced_volume) cluster = TissueClusters.from_labelmap_slice(sliced_img.T) reward = anatomy_based_rwd(cluster) - if reward < -0.1: + if reward < -0.05: show_clusters(cluster, sliced_img.T) print( - f"Volume {i + 1} and transformation {volume_transformation_action}, reward: {reward}", + f"Volume {i + 1} and transformation {volume_transformation_action}, reward: {reward}.", ) - plt.show() + plt.show() j += 1 assert ( - reward > -0.1 + reward > -0.05 ), f"Reward: {reward} for volume {i + 1} and transformation {volume_transformation_action}"