From f006d31578318367b51ecf766b670b4e527bea07 Mon Sep 17 00:00:00 2001 From: Annemijn Jonkman Date: Fri, 1 Sep 2023 16:44:22 +0200 Subject: [PATCH 01/55] Add ROISelection and FunctionalLungspace --- eitprocessing/roi_selection/__init__.py | 7 +++++ .../roi_selection/functionallungspace.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 eitprocessing/roi_selection/__init__.py create mode 100644 eitprocessing/roi_selection/functionallungspace.py diff --git a/eitprocessing/roi_selection/__init__.py b/eitprocessing/roi_selection/__init__.py new file mode 100644 index 000000000..831278074 --- /dev/null +++ b/eitprocessing/roi_selection/__init__.py @@ -0,0 +1,7 @@ +class ROISelection: + def minimal_cluster( + self, + map, + ): + #TODO create minimal cluster size selector + pass \ No newline at end of file diff --git a/eitprocessing/roi_selection/functionallungspace.py b/eitprocessing/roi_selection/functionallungspace.py new file mode 100644 index 000000000..7d4702d8a --- /dev/null +++ b/eitprocessing/roi_selection/functionallungspace.py @@ -0,0 +1,29 @@ +import numpy as np +from . import ROISelection + +class FunctionalLungSpace(ROISelection): + def __init__( + self, + threshold: float, + min_output_size: int = 0, + min_cluster_size: int = 0, + ): + self.threshold = threshold + self.min_output_size = min_output_size + self.min_cluster_size = min_cluster_size + + def find_ROI( + self, + data: np.ndarray, + ): + max_pixel = np.nanmax(data, axis=-1) + min_pixel = np.nanmin(data, axis=-1) + pixel_amplitude = max_pixel - min_pixel + max_pixel_amplitude = np.max(pixel_amplitude) + + output = pixel_amplitude > max_pixel_amplitude * self.threshold + output = self.minimal_cluster(output) + + return output + + From 9328a986381bdf1d57d775216b7b51fa37e6bea4 Mon Sep 17 00:00:00 2001 From: Annemijn Jonkman Date: Fri, 1 Sep 2023 16:44:46 +0200 Subject: [PATCH 02/55] Unfinished GridSelection algorithm --- eitprocessing/roi_selection/gridselection.py | 31 +++ notebooks/find_grid.ipynb | 236 +++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 eitprocessing/roi_selection/gridselection.py create mode 100644 notebooks/find_grid.ipynb diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py new file mode 100644 index 000000000..aa2ca8fae --- /dev/null +++ b/eitprocessing/roi_selection/gridselection.py @@ -0,0 +1,31 @@ +import numpy as np +from . import ROISelection + + +class GridSelection(ROISelection): + def __init__( + self, + rows: int, + cols: int, + split_pixels: bool = False, + ): + self.rows = rows + self.cols = cols + self.split_pixels = split_pixels + + def find_grid(self, data): + # Detect upper, lower, left and right column of grid selection, + # i.e, find the first row and column where the sum of pixels in that column is not NaN + numeric_cols = (~np.isnan(data)).sum(0) + cols_with_numbers = np.argwhere(numeric_cols > 0) + first_col_with_number = cols_with_numbers.min() + last_col_with_numer = cols_with_numbers.max() + + n_columns = last_col_with_numer - first_col_with_number + 1 + + n_columns_per_group = n_columns / self.cols + + splits = np.arange(self.cols) * n_columns_per_group + first_col_with_number + + + \ No newline at end of file diff --git a/notebooks/find_grid.ipynb b/notebooks/find_grid.ipynb new file mode 100644 index 000000000..7b08cee73 --- /dev/null +++ b/notebooks/find_grid.ipynb @@ -0,0 +1,236 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 110, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[nan, nan, nan, nan, 1.],\n", + " [nan, 1., 1., 3., nan],\n", + " [nan, 1., 2., 3., nan],\n", + " [nan, nan, nan, nan, nan]])" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from types import SimpleNamespace\n", + "import numpy as np\n", + "import bisect\n", + "a = np.array([[np.nan, np.nan, np.nan, np.nan, 1],[np.nan, 1, 1, 3, np.nan], [np.nan, 1, 2, 3, np.nan], [np.nan, np.nan, np.nan, np.nan, np.nan]])\n", + "\n", + "data = a\n", + "display(data)\n", + "\n", + "self = SimpleNamespace()\n", + "self.h_split = 3\n", + "self.v_split = 2" + ] + }, + { + "cell_type": "code", + "execution_count": 111, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([0, 2, 2, 2, 1])" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "array([[1],\n", + " [2],\n", + " [3],\n", + " [4]])" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "4" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "[1, 3, 4, 5]" + ] + }, + "execution_count": 111, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "numeric_cols = (~np.isnan(data)).sum(0)\n", + "cols_with_numbers = np.argwhere(numeric_cols > 0)\n", + "first_col_with_number = cols_with_numbers.min()\n", + "last_col_with_numer = cols_with_numbers.max()\n", + "\n", + "n_columns = last_col_with_numer - first_col_with_number + 1\n", + "\n", + "n_columns_per_group = n_columns / self.h_split\n", + "\n", + "display(numeric_cols, cols_with_numbers, first_col_with_number, last_col_with_numer)\n", + "\n", + "col_splits = [\n", + " first_col_with_number + bisect.bisect_left(np.arange(n_columns), c * n_columns_per_group)\n", + " for c in range(0, self.h_split)\n", + "] + [last_col_with_numer+1]\n", + "\n", + "col_splits\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 112, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[False, False, False, False, True],\n", + " [False, True, True, True, False],\n", + " [False, True, True, True, False],\n", + " [False, False, False, False, False]])" + ] + }, + "execution_count": 112, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "A = ~np.isnan(data)\n", + "A" + ] + }, + { + "cell_type": "code", + "execution_count": 113, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[False, False, False, False, False],\n", + " [False, True, True, False, False],\n", + " [False, True, True, False, False],\n", + " [False, False, False, False, False]])" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "array([[False, False, False, False, False],\n", + " [False, False, False, True, False],\n", + " [False, False, False, True, False],\n", + " [False, False, False, False, False]])" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "array([[False, False, False, False, True],\n", + " [False, False, False, False, False],\n", + " [False, False, False, False, False],\n", + " [False, False, False, False, False]])" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "import itertools\n", + "for splits in itertools.pairwise(col_splits):\n", + " matrix = A.copy()\n", + " matrix[:, :splits[0]] = False\n", + " matrix[:, splits[1]:] = False\n", + " display(matrix)" + ] + }, + { + "cell_type": "code", + "execution_count": 87, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "[0, 2]" + ] + }, + "execution_count": 87, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "col_splits" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "alive", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} From 7ada94c7e8a5e1beb2c35eeb5ae2dbe234e63ab1 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 4 Sep 2023 15:53:59 +0200 Subject: [PATCH 03/55] Fix unwanted redefinition of built-in variable --- eitprocessing/roi_selection/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eitprocessing/roi_selection/__init__.py b/eitprocessing/roi_selection/__init__.py index 831278074..a4c4c1c9e 100644 --- a/eitprocessing/roi_selection/__init__.py +++ b/eitprocessing/roi_selection/__init__.py @@ -1,7 +1,7 @@ class ROISelection: def minimal_cluster( - self, - map, + self, + pixel_map, ): - #TODO create minimal cluster size selector - pass \ No newline at end of file + # TODO create minimal cluster size selector + pass From cd21b8ce0d72c227230ff023aee18afd8f3f2ec8 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 4 Sep 2023 17:19:34 +0200 Subject: [PATCH 04/55] Update GridSelection.find_grid() function --- eitprocessing/roi_selection/gridselection.py | 76 ++++++++++++++++---- 1 file changed, 62 insertions(+), 14 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index aa2ca8fae..4ff7d0bcc 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -1,3 +1,5 @@ +import bisect +import itertools import numpy as np from . import ROISelection @@ -5,27 +7,73 @@ class GridSelection(ROISelection): def __init__( self, - rows: int, - cols: int, + v_split: int, + h_split: int, split_pixels: bool = False, ): - self.rows = rows - self.cols = cols + self.h_split = h_split + self.v_split = v_split self.split_pixels = split_pixels - def find_grid(self, data): - # Detect upper, lower, left and right column of grid selection, + def find_grid(self, data) -> list: + n_rows = data.shape[0] + n_columns = data.shape[1] + if self.h_split > n_columns: + raise ValueError( + "can't split a matrix into more horizontal regions than columns" + ) + + if self.v_split > n_rows: + raise ValueError( + "can't split a matrix into more vertical regions than rows" + ) + + if self.split_pixels: + raise NotImplementedError() + return self._find_grid_split_pixels(data) + + return self._find_grid_no_split_pixels(data) + + def _find_grid_no_split_pixels(self, data): + # Detect upper, lower, left and right column of grid selection, # i.e, find the first row and column where the sum of pixels in that column is not NaN - numeric_cols = (~np.isnan(data)).sum(0) - cols_with_numbers = np.argwhere(numeric_cols > 0) - first_col_with_number = cols_with_numbers.min() - last_col_with_numer = cols_with_numbers.max() - n_columns = last_col_with_numer - first_col_with_number + 1 + is_numeric = ~np.isnan(data) + + def get_region_boundaries(axis): + n_regions = self.h_split if axis == 0 else self.v_split + num_numeric_cells_in_vector = is_numeric.sum(axis) + vectors_with_numbers = np.argwhere(num_numeric_cells_in_vector > 0) + first_vector_with_number = vectors_with_numbers.min() + last_vector_with_numer = vectors_with_numbers.max() + + n_vectors = last_vector_with_numer - first_vector_with_number + 1 + + n_vectors_per_region = n_vectors / n_regions + + region_boundaries = [ + first_vector_with_number + + bisect.bisect_left(np.arange(n_vectors), c * n_vectors_per_region) + for c in range(0, n_regions) + ] + [last_vector_with_numer + 1] + return region_boundaries + + h_boundaries = get_region_boundaries(0) + v_boundaries = get_region_boundaries(1) + + matrices = [] + for v_start, v_end in itertools.pairwise(v_boundaries): + for h_start, h_end in itertools.pairwise(h_boundaries): + matrix = np.copy(is_numeric) + matrix[:, :h_start] = False + matrix[:, h_end:] = False + matrix[:v_start, :] = False + matrix[v_end:, :] = False + matrices.append(matrix) - n_columns_per_group = n_columns / self.cols - - splits = np.arange(self.cols) * n_columns_per_group + first_col_with_number + return matrices + def _find_grid_split_pixels(self, data): + pass \ No newline at end of file From 4987a62716e15d211bb7b401a45a75c9f81ff08c Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 4 Sep 2023 17:15:01 +0200 Subject: [PATCH 05/55] Add tests for GridSelection --- tests/test_gridselection.py | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/test_gridselection.py diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py new file mode 100644 index 000000000..8b2cd32a7 --- /dev/null +++ b/tests/test_gridselection.py @@ -0,0 +1,50 @@ +import numpy as np +import pytest +from eitprocessing.roi_selection.gridselection import GridSelection + + +@pytest.mark.parametrize( + "n_rows,n_columns,v_split,h_split,result", + [ + (2, 1, 2, 1, "T,F;F,T"), + (3, 1, 3, 1, "T,F,F;F,T,F;F,F,T"), + (1, 3, 1, 3, "TFF;FTF;FFT"), + (1, 3, 1, 2, "TTF;FFT"), + ( + 4, + 4, + 2, + 2, + "TTFF,TTFF,FFFF,FFFF;" + "FFTT,FFTT,FFFF,FFFF;" + "FFFF,FFFF,TTFF,TTFF;" + "FFFF,FFFF,FFTT,FFTT", + ), + ( + 5, + 5, + 2, + 2, + "TTTFF,TTTFF,TTTFF,FFFFF,FFFFF;" + "FFFTT,FFFTT,FFFTT,FFFFF,FFFFF;" + "FFFFF,FFFFF,FFFFF,TTTFF,TTTFF;" + "FFFFF,FFFFF,FFFFF,FFFTT,FFFTT", + ), + (5, 2, 1, 2, "TF,TF,TF,TF,TF;FT,FT,FT,FT,FT"), + ], +) +def test_no_split_pixels_no_nans(n_rows, n_columns, v_split, h_split, result): + data = np.ones((n_rows, n_columns)) + + gs = GridSelection(v_split, h_split) + matrices = gs.find_grid(data) + + assert len(matrices) == h_split * v_split + num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) + assert np.array_equal(num_appearances, np.ones(data.shape)) + + result = [ + np.array([tuple(row) for row in matrix.split(",")]) == "T" + for matrix in result.split(";") + ] + assert np.array_equal(matrices, result) From 26f6a91433701cd2e76e24072fd45533e233a7e9 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 4 Sep 2023 17:18:12 +0200 Subject: [PATCH 06/55] Add function to GridSelection to get a layout of the returned matrices --- eitprocessing/roi_selection/gridselection.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 4ff7d0bcc..2188e0432 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -76,4 +76,7 @@ def get_region_boundaries(axis): def _find_grid_split_pixels(self, data): pass - \ No newline at end of file + def matrix_layout(self): + """Returns an array showing the layout of the matrices returned by `find_grid`.""" + n_groups = self.v_split * self.h_split + return np.reshape(np.arange(n_groups), (self.v_split, self.h_split)) From 67c9c6286c3a86e3db8ff209ef85f6b73cd3ce18 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 4 Sep 2023 17:24:09 +0200 Subject: [PATCH 07/55] Add some scratchpad code to notebook --- notebooks/find_grid.ipynb | 119 +++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 9 deletions(-) diff --git a/notebooks/find_grid.ipynb b/notebooks/find_grid.ipynb index 7b08cee73..55ed40823 100644 --- a/notebooks/find_grid.ipynb +++ b/notebooks/find_grid.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 110, + "execution_count": 1, "metadata": {}, "outputs": [ { @@ -28,13 +28,37 @@ "display(data)\n", "\n", "self = SimpleNamespace()\n", - "self.h_split = 3\n", + "self.h_split = 2\n", "self.v_split = 2" ] }, { "cell_type": "code", - "execution_count": 111, + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[False, False, False, False, True],\n", + " [False, True, True, True, False],\n", + " [False, True, True, True, False],\n", + " [False, False, False, False, False]])" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "is_numeric = ~np.isnan(data)\n", + "is_numeric[:]" + ] + }, + { + "cell_type": "code", + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -79,10 +103,10 @@ { "data": { "text/plain": [ - "[1, 3, 4, 5]" + "[1, 3, 5]" ] }, - "execution_count": 111, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -205,10 +229,87 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 37, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Test how to stack the resulting matrices, and check whether every cell is represented once\n", + "\n", + "from eitprocessing.roi_selection.gridselection import GridSelection\n", + "import numpy as np\n", + "\n", + "g = GridSelection(3, 4, False)\n", + "data = np.full((32, 32), 1)\n", + "\n", + "matrices = g.find_grid(data)\n", + "np.array_equal(np.sum(np.stack(matrices, axis=-1), axis=-1), np.ones(data.shape))" + ] + }, + { + "cell_type": "code", + "execution_count": 35, "metadata": {}, - "outputs": [], - "source": [] + "outputs": [ + { + "data": { + "text/plain": [ + "[array([[ True, False, False],\n", + " [False, True, False],\n", + " [False, False, True]]),\n", + " array([[ True, False],\n", + " [False, True]])]" + ] + }, + "execution_count": 35, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Test way to parse string to True/False matrix\n", + "\n", + "result = 'TFF,FTF,FFT;TF,FT'\n", + "array = [np.array([tuple(row) for row in matrix.split(',')]) == 'T' for matrix in result.split(';')] \n", + "array" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "array([[ 0, 1, 2, 3],\n", + " [ 4, 5, 6, 7],\n", + " [ 8, 9, 10, 11]])" + ] + }, + "execution_count": 33, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Test way to create a layout map of the returned matrices\n", + "\n", + "v_split = 3\n", + "h_split = 4\n", + "n_groups = v_split * h_split\n", + "np.reshape(np.arange(n_groups), (v_split, h_split))" + ] } ], "metadata": { @@ -227,7 +328,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.10.5" }, "orig_nbformat": 4 }, From 9bce61059a4bdd9c4dedcf667c711dbc3ad0f88d Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Thu, 21 Sep 2023 19:54:48 +0200 Subject: [PATCH 08/55] Update region boundary finding algorithm --- .../roi_selection/functionallungspace.py | 5 ++-- eitprocessing/roi_selection/gridselection.py | 26 +++++++++---------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/eitprocessing/roi_selection/functionallungspace.py b/eitprocessing/roi_selection/functionallungspace.py index 7d4702d8a..442549da5 100644 --- a/eitprocessing/roi_selection/functionallungspace.py +++ b/eitprocessing/roi_selection/functionallungspace.py @@ -1,6 +1,7 @@ import numpy as np from . import ROISelection + class FunctionalLungSpace(ROISelection): def __init__( self, @@ -21,9 +22,7 @@ def find_ROI( pixel_amplitude = max_pixel - min_pixel max_pixel_amplitude = np.max(pixel_amplitude) - output = pixel_amplitude > max_pixel_amplitude * self.threshold + output = pixel_amplitude > (max_pixel_amplitude * self.threshold) output = self.minimal_cluster(output) return output - - diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 2188e0432..8d4ab3fd7 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -40,26 +40,24 @@ def _find_grid_no_split_pixels(self, data): is_numeric = ~np.isnan(data) - def get_region_boundaries(axis): - n_regions = self.h_split if axis == 0 else self.v_split - num_numeric_cells_in_vector = is_numeric.sum(axis) - vectors_with_numbers = np.argwhere(num_numeric_cells_in_vector > 0) - first_vector_with_number = vectors_with_numbers.min() - last_vector_with_numer = vectors_with_numbers.max() - - n_vectors = last_vector_with_numer - first_vector_with_number + 1 + def get_region_boundaries(axis, n_regions): + vector_has_numeric_cells = is_numeric.sum(axis) > 0 + numeric_vector_indices = np.argwhere(vector_has_numeric_cells) + first_numeric_vector = numeric_vector_indices.min() + last_vector_numeric = numeric_vector_indices.max() + n_vectors = last_vector_numeric - first_numeric_vector + 1 n_vectors_per_region = n_vectors / n_regions region_boundaries = [ - first_vector_with_number - + bisect.bisect_left(np.arange(n_vectors), c * n_vectors_per_region) - for c in range(0, n_regions) - ] + [last_vector_with_numer + 1] + first_numeric_vector + + bisect.bisect_left(np.arange(n_vectors) / n_vectors_per_region, c) + for c in range(n_regions + 1) + ] return region_boundaries - h_boundaries = get_region_boundaries(0) - v_boundaries = get_region_boundaries(1) + h_boundaries = get_region_boundaries(axis=0, n_regions=self.h_split) + v_boundaries = get_region_boundaries(axis=1, n_regions=self.v_split) matrices = [] for v_start, v_end in itertools.pairwise(v_boundaries): From 65dc08f29c5a07c87bde25d41e00f801c3a257e2 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Thu, 21 Sep 2023 19:55:09 +0200 Subject: [PATCH 09/55] Improve error messages --- eitprocessing/roi_selection/gridselection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 8d4ab3fd7..a46dad39f 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -20,12 +20,12 @@ def find_grid(self, data) -> list: n_columns = data.shape[1] if self.h_split > n_columns: raise ValueError( - "can't split a matrix into more horizontal regions than columns" + f"`h_split` ({self.h_split}) is larger than the number of columns ({n_columns})." ) if self.v_split > n_rows: raise ValueError( - "can't split a matrix into more vertical regions than rows" + f"`v_split` ({self.v_split}) is larger than the number or rows ({n_rows})." ) if self.split_pixels: From d6bf3e3ccaf4f98f3aead2126b1493f31535b524 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Thu, 21 Sep 2023 21:12:59 +0200 Subject: [PATCH 10/55] Expand tests for GridSelection Tests now include inputs with np.nan values and values that are not 1. --- tests/test_gridselection.py | 136 ++++++++++++++++++++++++++++++------ 1 file changed, 113 insertions(+), 23 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 8b2cd32a7..e00a2078b 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -3,48 +3,138 @@ from eitprocessing.roi_selection.gridselection import GridSelection +def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray]: + """Generates a list of matrices from a string containing a matrix representation. + + A matrix represtation contains one character per cell that describes the value of that cell. + Rows are delimited by commas. Matrices are delimited by semi-colons. + + The returned matrices by default have `np.floating` as dtype. When `boolean` is set to True, the + dtype is `bool`. That means that the actual values in the matrices depend on the value of `boolean`. + + The following characters are transformed to these corresponding values in either `np.floating` or + `bool` mode: + - T -> 1. or True + - F -> 0. or False + - 1 -> 1. or True + - N -> np.nan or False + - R -> np.random.int(1, 100) or True + + Examples: + >>> matrices_from_string("1T1,FNR") + [array([[ 1., 1., 1.], + [ 0., nan, 40.]])] + >>> matrices_from_string("1T1,FNR", boolean=True) + [array([[ True, True, True], + [False, False, True]])] + >>> matrices_from_string("RR,RR;RRR;1R") + [array([[21., 80.], + [43., 10.]]), + array([[26., 43., 62.]]), + array([[ 1., 89.]])] + """ + + matrices = [] + for part in string.split(";"): + str_matrix = np.array([tuple(row) for row in part.split(",")], dtype="object") + if boolean: + matrix = np.full(str_matrix.shape, False, dtype=bool) + matrix[np.nonzero(str_matrix == "N")] = False + matrix[np.nonzero(str_matrix == "1")] = True + matrix[np.nonzero(str_matrix == "R")] = True + + else: + matrix = np.full(str_matrix.shape, np.nan, dtype=np.floating) + matrix[np.nonzero(str_matrix == "N")] = np.nan + matrix[np.nonzero(str_matrix == "1")] = 1 + matrix = np.where( + str_matrix == "R", + np.random.default_rng().integers(1, 100, matrix.shape), + matrix, + ) + + matrix[np.nonzero(str_matrix == "T")] = True + matrix[np.nonzero(str_matrix == "F")] = False + + matrices.append(matrix) + + return matrices + + @pytest.mark.parametrize( - "n_rows,n_columns,v_split,h_split,result", + "shape,split_vh,result_string", [ - (2, 1, 2, 1, "T,F;F,T"), - (3, 1, 3, 1, "T,F,F;F,T,F;F,F,T"), - (1, 3, 1, 3, "TFF;FTF;FFT"), - (1, 3, 1, 2, "TTF;FFT"), + ((2, 1), (2, 1), "T,F;F,T"), + ((3, 1), (3, 1), "T,F,F;F,T,F;F,F,T"), + ((1, 3), (1, 3), "TFF;FTF;FFT"), + ((1, 3), (1, 2), "TTF;FFT"), ( - 4, - 4, - 2, - 2, + (4, 4), + (2, 2), "TTFF,TTFF,FFFF,FFFF;" "FFTT,FFTT,FFFF,FFFF;" "FFFF,FFFF,TTFF,TTFF;" "FFFF,FFFF,FFTT,FFTT", ), ( - 5, - 5, - 2, - 2, + (5, 5), + (2, 2), "TTTFF,TTTFF,TTTFF,FFFFF,FFFFF;" "FFFTT,FFFTT,FFFTT,FFFFF,FFFFF;" "FFFFF,FFFFF,FFFFF,TTTFF,TTTFF;" "FFFFF,FFFFF,FFFFF,FFFTT,FFFTT", ), - (5, 2, 1, 2, "TF,TF,TF,TF,TF;FT,FT,FT,FT,FT"), + ((5, 2), (1, 2), "TF,TF,TF,TF,TF;FT,FT,FT,FT,FT"), + ((1, 9), (1, 6), "TTFFFFFFF;FFTFFFFFF;FFFTTFFFF;FFFFFTFFF;FFFFFFTTF;FFFFFFFFT"), + ((2, 2), (1, 1), "TT,TT"), ], ) -def test_no_split_pixels_no_nans(n_rows, n_columns, v_split, h_split, result): - data = np.ones((n_rows, n_columns)) +def test_no_split_pixels_no_nans(shape, split_vh, result_string): + data = np.random.default_rng().integers(1, 100, shape) + result_matrices = matrices_from_string(result_string) - gs = GridSelection(v_split, h_split) + gs = GridSelection(*split_vh) matrices = gs.find_grid(data) - assert len(matrices) == h_split * v_split num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) - assert np.array_equal(num_appearances, np.ones(data.shape)) - result = [ - np.array([tuple(row) for row in matrix.split(",")]) == "T" - for matrix in result.split(";") - ] + assert len(matrices) == np.prod(split_vh) + assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) + assert np.array_equal(matrices, result_matrices) + + +@pytest.mark.parametrize( + "data_string,split_vh,result_string", + [ + ("NNN,NRR,NRR", (1, 1), "NNN,NTT,NTT"), + ( + "NNN,RRR,RRR,RRR,RRR", + (2, 2), + "FFF,TTF,TTF,FFF,FFF;" + "FFF,FFT,FFT,FFF,FFF;" + "FFF,FFF,FFF,TTF,TTF;" + "FFF,FFF,FFF,FFT,FFT", + ), + ( + "NNNNNN,NNNNNN,NRRRRR,RNRRRR,NNNNNN", + (2, 2), + "FFFFFF,FFFFFF,FTTFFF,FFFFFF,FFFFFF;" + "FFFFFF,FFFFFF,FFFTTT,FFFFFF,FFFFFF;" + "FFFFFF,FFFFFF,FFFFFF,TFTFFF,FFFFFF;" + "FFFFFF,FFFFFF,FFFFFF,FFFTTT,FFFFFF", + ), + ], +) +def test_no_split_pixels_nans(data_string, split_vh, result_string): + data = matrices_from_string(data_string)[0] + result = matrices_from_string(result_string, boolean=True) + + v_split, h_split = split_vh + gs = GridSelection(v_split, h_split) + + matrices = gs.find_grid(data) + num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) + + assert len(matrices) == h_split * v_split + assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) assert np.array_equal(matrices, result) From d71da311ac69c8f800f72988132ced014974bde0 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:24:20 +0200 Subject: [PATCH 11/55] Convert GridSelection to a dataclass --- eitprocessing/roi_selection/gridselection.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index a46dad39f..922eace72 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -1,19 +1,15 @@ import bisect import itertools +from dataclasses import dataclass import numpy as np from . import ROISelection +@dataclass class GridSelection(ROISelection): - def __init__( - self, - v_split: int, - h_split: int, - split_pixels: bool = False, - ): - self.h_split = h_split - self.v_split = v_split - self.split_pixels = split_pixels + v_split: int + h_split: int + split_pixels: bool = False def find_grid(self, data) -> list: n_rows = data.shape[0] From 274a35e45398313db8c4b171d9ffafcac3d0fedf Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:26:14 +0200 Subject: [PATCH 12/55] Add module specific exceptions --- eitprocessing/roi_selection/gridselection.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 922eace72..1ac38a302 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -15,12 +15,12 @@ def find_grid(self, data) -> list: n_rows = data.shape[0] n_columns = data.shape[1] if self.h_split > n_columns: - raise ValueError( + raise InvalidHorizontalDivision( f"`h_split` ({self.h_split}) is larger than the number of columns ({n_columns})." ) if self.v_split > n_rows: - raise ValueError( + raise InvalidVerticalDivision( f"`v_split` ({self.v_split}) is larger than the number or rows ({n_rows})." ) @@ -74,3 +74,15 @@ def matrix_layout(self): """Returns an array showing the layout of the matrices returned by `find_grid`.""" n_groups = self.v_split * self.h_split return np.reshape(np.arange(n_groups), (self.v_split, self.h_split)) + + +class InvalidDivision(Exception): + """Raised when the data can't be divided into regions.""" + + +class InvalidHorizontalDivision(InvalidDivision): + """Raised when the data can't be divided into horizontal regions.""" + + +class InvalidVerticalDivision(InvalidDivision): + """Raised when the data can't be divided into vertical regions.""" From c5958cdf918e256367945b4ac44a036b2ff25f48 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:25:09 +0200 Subject: [PATCH 13/55] Add value checks in __post_init__ --- eitprocessing/roi_selection/gridselection.py | 30 ++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 1ac38a302..25ebddd08 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -11,6 +11,36 @@ class GridSelection(ROISelection): h_split: int split_pixels: bool = False + def __post_init__(self): + if not isinstance(self.v_split, int): + raise TypeError( + "Invalid type for `h_split`. " + f"Should be `int`, not {type(self.h_split)}." + ) + + if not isinstance(self.h_split, int): + raise TypeError( + "Invalid type for `h_split`. " + f"Should be `int`, not {type(self.h_split)}." + ) + + if self.v_split < 1: + raise InvalidVerticalDivision("`v_split` can't be smaller than 1.") + + if self.h_split < 1: + raise InvalidHorizontalDivision("`h_split` can't be smaller than 1.") + + if not isinstance(self.split_pixels, bool): + raise TypeError( + "Invalid type for `split_pixels`. " + f"Should be `bool`, not {type(self.split_pixels)}" + ) + + if self.split_pixels is True: + raise NotImplementedError( + "GrisSelection has no support for split pixels yet." + ) + def find_grid(self, data) -> list: n_rows = data.shape[0] n_columns = data.shape[1] From d7f2f00f5b364c67f99d242bc6694e30a11ba993 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:27:25 +0200 Subject: [PATCH 14/55] Move NotImplementedError to proper place --- eitprocessing/roi_selection/__init__.py | 2 +- eitprocessing/roi_selection/gridselection.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/eitprocessing/roi_selection/__init__.py b/eitprocessing/roi_selection/__init__.py index a4c4c1c9e..245a0f571 100644 --- a/eitprocessing/roi_selection/__init__.py +++ b/eitprocessing/roi_selection/__init__.py @@ -4,4 +4,4 @@ def minimal_cluster( pixel_map, ): # TODO create minimal cluster size selector - pass + raise NotImplementedError() diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 25ebddd08..394096a43 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -55,7 +55,6 @@ def find_grid(self, data) -> list: ) if self.split_pixels: - raise NotImplementedError() return self._find_grid_split_pixels(data) return self._find_grid_no_split_pixels(data) @@ -98,7 +97,7 @@ def get_region_boundaries(axis, n_regions): return matrices def _find_grid_split_pixels(self, data): - pass + raise NotImplementedError() def matrix_layout(self): """Returns an array showing the layout of the matrices returned by `find_grid`.""" From 0d0dd2f95ddc922bb38f5610c3ce955eeaafd7e9 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:28:44 +0200 Subject: [PATCH 15/55] Update get_region_boundaries method with warnings --- eitprocessing/roi_selection/gridselection.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 394096a43..02995f1be 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -1,6 +1,8 @@ import bisect import itertools +import warnings from dataclasses import dataclass +from typing import Literal import numpy as np from . import ROISelection @@ -65,7 +67,11 @@ def _find_grid_no_split_pixels(self, data): is_numeric = ~np.isnan(data) - def get_region_boundaries(axis, n_regions): + def get_region_boundaries( + orientation: Literal["horizontal", "vertical"], n_regions: int + ): + horizontal = orientation == "horizontal" + axis = 0 if horizontal else 1 vector_has_numeric_cells = is_numeric.sum(axis) > 0 numeric_vector_indices = np.argwhere(vector_has_numeric_cells) first_numeric_vector = numeric_vector_indices.min() @@ -74,6 +80,13 @@ def get_region_boundaries(axis, n_regions): n_vectors = last_vector_numeric - first_numeric_vector + 1 n_vectors_per_region = n_vectors / n_regions + if n_vectors_per_region % 1 > 0: + warnings.warn( + f"The {orientation} groups will not have an equal number of {'columns' if horizontal else 'rows'}. " + f"{n_vectors} is not equally divisible by {n_regions}.", + RuntimeWarning, + ) + region_boundaries = [ first_numeric_vector + bisect.bisect_left(np.arange(n_vectors) / n_vectors_per_region, c) @@ -81,8 +94,8 @@ def get_region_boundaries(axis, n_regions): ] return region_boundaries - h_boundaries = get_region_boundaries(axis=0, n_regions=self.h_split) - v_boundaries = get_region_boundaries(axis=1, n_regions=self.v_split) + h_boundaries = get_region_boundaries("horizontal", n_regions=self.h_split) + v_boundaries = get_region_boundaries("vertical", n_regions=self.v_split) matrices = [] for v_start, v_end in itertools.pairwise(v_boundaries): From 45b72d65a97a2e925ba6c2b29e2588ff07189549 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:29:13 +0200 Subject: [PATCH 16/55] Implement shorter unpacking --- eitprocessing/roi_selection/gridselection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 02995f1be..603677f13 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -44,8 +44,8 @@ def __post_init__(self): ) def find_grid(self, data) -> list: - n_rows = data.shape[0] - n_columns = data.shape[1] + n_rows, n_columns = data.shape + if self.h_split > n_columns: raise InvalidHorizontalDivision( f"`h_split` ({self.h_split}) is larger than the number of columns ({n_columns})." From 33ab9ae49fbdc30cdaf83473c72f743958372d92 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 10:30:01 +0200 Subject: [PATCH 17/55] Add tests for initialization, warnings and exceptions --- tests/test_gridselection.py | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index e00a2078b..b13a3a432 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -1,6 +1,10 @@ +import warnings import numpy as np import pytest from eitprocessing.roi_selection.gridselection import GridSelection +from eitprocessing.roi_selection.gridselection import InvalidDivision +from eitprocessing.roi_selection.gridselection import InvalidHorizontalDivision +from eitprocessing.roi_selection.gridselection import InvalidVerticalDivision def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray]: @@ -61,6 +65,31 @@ def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray] return matrices +@pytest.mark.parametrize( + "v_split,h_split,split_pixels,exception_type", + [ + (1, 1, False, None), + (1, 1, True, NotImplementedError), + (0, 1, False, InvalidVerticalDivision), + (-1, 1, False, InvalidVerticalDivision), + (1.1, 1, False, TypeError), + (1, 0, False, InvalidHorizontalDivision), + (1, -1, False, InvalidHorizontalDivision), + (1, 1.1, False, TypeError), + (2, 2, "not a boolean", TypeError), + (2, 2, 1, TypeError), + (2, 2, 0, TypeError), + ], +) +def test_initialisation(v_split, h_split, split_pixels, exception_type): + if exception_type is None: + GridSelection(v_split, h_split, split_pixels) + + else: + with pytest.raises(exception_type): + GridSelection(v_split, h_split, split_pixels) + + @pytest.mark.parametrize( "shape,split_vh,result_string", [ @@ -138,3 +167,68 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): assert len(matrices) == h_split * v_split assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) assert np.array_equal(matrices, result) + + +@pytest.mark.parametrize( + "data_string,split_vh,warning_type", + [ + ("RR,RR", (2, 2), None), + ("RRR,RRR", (2, 2), RuntimeWarning), + ("RRR,RRR", (1, 3), None), + ("RRRR,RRRR", (1, 3), RuntimeWarning), + ("RR,RR,RR", (2, 1), RuntimeWarning), + ("RR,RR,RR", (3, 1), None), + ("NN,RR,RR", (3, 1), RuntimeWarning), + ("NN,RR,RR", (2, 1), None), + ], +) +def test_warnings(data_string, split_vh, warning_type): + data = matrices_from_string(data_string)[0] + gs = GridSelection(*split_vh) + + if warning_type is None: + # catch all warnings and raises them + with warnings.catch_warnings(): + warnings.simplefilter("error") + gs.find_grid(data) + else: + with pytest.warns(warning_type): + gs.find_grid(data) + + +@pytest.mark.parametrize( + "data_string,split_vh,exception_type", + [ + ("RR,RR", (2, 2), None), + ("RR,RR", (3, 1), InvalidVerticalDivision), + ("RR,RR", (1, 3), InvalidHorizontalDivision), + ("RR,RR", (3, 1), InvalidDivision), + ("RR,RR", (1, 3), InvalidDivision), + ], +) +def test_exceptions(data_string, split_vh, exception_type): + data = matrices_from_string(data_string)[0] + gs = GridSelection(*split_vh) + + if exception_type is None: + gs.find_grid(data) + + else: + with pytest.raises(exception_type): + gs.find_grid(data) + + +def test_split_pixels(): + with pytest.raises(NotImplementedError): + gs = GridSelection(1, 1, True) + + gs = GridSelection(1, 1, False) + gs.split_pixels = True + data = np.ones((2, 2)) + with pytest.raises(NotImplementedError): + gs.find_grid(data) + + +def test_matrix_layout(): + # TODO: write tests for matrix layout method + pass From 9b0499451f31ad3bb6ea11777348ea3507bf2f04 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 22 Sep 2023 15:27:42 +0200 Subject: [PATCH 18/55] Add tests for matrix_layout() --- tests/test_gridselection.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index b13a3a432..c43cfd515 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -229,6 +229,18 @@ def test_split_pixels(): gs.find_grid(data) -def test_matrix_layout(): - # TODO: write tests for matrix layout method - pass +@pytest.mark.parametrize( + "split_vh,result", + [ + ((1, 1), [[0]]), + ((1, 2), [[0, 1]]), + ((2, 1), [[0], [1]]), + ((2, 2), [[0, 1], [2, 3]]), + ((3, 4), [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]), + ], +) +def test_matrix_layout(split_vh, result): + gs = GridSelection(*split_vh) + layout = gs.matrix_layout() + + assert np.array_equal(layout, np.array(result)) From 27b78b5cf5f6dcc4a16f58a8b443b1401a219a65 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 25 Sep 2023 10:45:01 +0200 Subject: [PATCH 19/55] Create convenience classes of most-used cases --- eitprocessing/roi_selection/gridselection.py | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 603677f13..c6480ed22 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -128,3 +128,31 @@ class InvalidHorizontalDivision(InvalidDivision): class InvalidVerticalDivision(InvalidDivision): """Raised when the data can't be divided into vertical regions.""" + + +@dataclass +class VentralAndDorsal(GridSelection): + v_split: Literal[2] = 2 + h_split: Literal[0] = 0 + split_pixels: bool = False + + +@dataclass +class RightAndLeft(GridSelection): + v_split: Literal[0] = 0 + h_split: Literal[2] = 2 + split_pixels: bool = False + + +@dataclass +class FourLayers(GridSelection): + v_split: Literal[4] = 4 + h_split: Literal[0] = 0 + split_pixels: bool = False + + +@dataclass +class Quadrants(GridSelection): + v_split: Literal[2] = 2 + h_split: Literal[2] = 2 + split_pixels: bool = False From a8856e1d947f21c1c960c9a785eac8fa2281302c Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Mon, 25 Sep 2023 10:45:27 +0200 Subject: [PATCH 20/55] Add docstring to GridSelection class --- eitprocessing/roi_selection/gridselection.py | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index c6480ed22..c8c5e8515 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -9,6 +9,39 @@ @dataclass class GridSelection(ROISelection): + """Create regions of interest by division into a grid. + + GridSelection allows for the creation a list of matrices that can be used to divide a + two-dimensional array into several groups structured in a grid. An instance of GridSelection + contains information about how to subdivide an input matrix. Calling `find_grid(data)` results + in a list of matrices with the same dimension as `data`, each representing a single group. A + matrix contains the value False or 0 for pixels that do not belong to the group, and the value + True, 1 or any number between 0 and 1 for pixels that (partly) belong to the group. + + Common grids are pre-defined: + - VentralAndDorsal: vertically divided into ventral and dorsal; + - RightAndLeft: horizontally divided into anatomical right and left; NB: anatomical right is + the left side of the matrix; + - FourLayers: vertically divided into ventral, mid-ventral, mid-dorsal and dorsal; + - Quadrants: vertically and horizontally divided into four quadrants. + + Args: + v_split: The number of vertical groups. Must be 1 or larger. + h_split: The number of horizontal groups. Must be 1 or larger. + split_pixels: Allows rows and columns to be split over two groups. Currently not + implemented. + + Example: + >>> gs = GridSelection(3, 1) + >>> matrices = gs.find_grid(included_pixels) + >>> sum_group1 = np.sum(matrices[0] * pixel_amplitude) + >>> print(gs.matrix_layout()) + array([[0], + [1], + [2]]) + + """ + v_split: int h_split: int split_pixels: bool = False From 27df3997c56a65b05ebedb1dc9cc8da4dc67c97b Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 26 Sep 2023 09:50:00 +0200 Subject: [PATCH 21/55] Remove v_split and h_split arguments from convenience classes' __init__ --- eitprocessing/roi_selection/gridselection.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index c8c5e8515..d1dedfc36 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -2,6 +2,7 @@ import itertools import warnings from dataclasses import dataclass +from dataclasses import field from typing import Literal import numpy as np from . import ROISelection @@ -165,27 +166,27 @@ class InvalidVerticalDivision(InvalidDivision): @dataclass class VentralAndDorsal(GridSelection): - v_split: Literal[2] = 2 - h_split: Literal[0] = 0 + v_split: Literal[2] = field(default=2, init=False) + h_split: Literal[1] = field(default=1, init=False) split_pixels: bool = False @dataclass class RightAndLeft(GridSelection): - v_split: Literal[0] = 0 - h_split: Literal[2] = 2 + v_split: Literal[1] = field(default=1, init=False) + h_split: Literal[2] = field(default=2, init=False) split_pixels: bool = False @dataclass class FourLayers(GridSelection): - v_split: Literal[4] = 4 - h_split: Literal[0] = 0 + v_split: Literal[4] = field(default=4, init=False) + h_split: Literal[1] = field(default=1, init=False) split_pixels: bool = False @dataclass class Quadrants(GridSelection): - v_split: Literal[2] = 2 - h_split: Literal[2] = 2 + v_split: Literal[2] = field(default=2, init=False) + h_split: Literal[2] = field(default=2, init=False) split_pixels: bool = False From c86321e5fe418ffd2f3d8143be5ab41d6a52f1b7 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 26 Sep 2023 09:50:43 +0200 Subject: [PATCH 22/55] Add split pixels functionality --- eitprocessing/roi_selection/gridselection.py | 76 +++++++++++++++++--- 1 file changed, 67 insertions(+), 9 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index d1dedfc36..336345665 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -72,14 +72,13 @@ def __post_init__(self): f"Should be `bool`, not {type(self.split_pixels)}" ) - if self.split_pixels is True: - raise NotImplementedError( - "GrisSelection has no support for split pixels yet." - ) - def find_grid(self, data) -> list: n_rows, n_columns = data.shape + if self.split_pixels: + # TODO: create warning if number of regions > number of columns/rows + return self._find_grid_split_pixels(data) + if self.h_split > n_columns: raise InvalidHorizontalDivision( f"`h_split` ({self.h_split}) is larger than the number of columns ({n_columns})." @@ -90,9 +89,6 @@ def find_grid(self, data) -> list: f"`v_split` ({self.v_split}) is larger than the number or rows ({n_rows})." ) - if self.split_pixels: - return self._find_grid_split_pixels(data) - return self._find_grid_no_split_pixels(data) def _find_grid_no_split_pixels(self, data): @@ -144,7 +140,69 @@ def get_region_boundaries( return matrices def _find_grid_split_pixels(self, data): - raise NotImplementedError() + def create_grouping_vector(matrix, axis, n_groups): + """Create a grouping vector to split vector into `n` groups.""" + + # create a vector that is nan if the entire column/row is nan, 1 otherwise + nan = np.all(np.isnan(matrix), axis=axis) + vector = np.ones(nan.shape) + vector[nan] = np.nan + + # remove non-numeric (nan) elements at vector ends + # nan elements between numeric elements are kept + numeric_element_indices = np.argwhere(~np.isnan(vector)) + first_num_element = numeric_element_indices.min() + last_num_element = numeric_element_indices.max() + n_elements = last_num_element - first_num_element + 1 + + group_size = n_elements / n_groups + + # find the right boundaries (upper values) of each group + right_boundaries = (np.arange(n_groups) + 1) * group_size + right_boundaries = right_boundaries[:, None] # converts it to a row vector + + # each row in the base represents one group + base = np.tile(np.arange(n_elements), (n_groups, 1)) + + # if the element number is higher than the split, it does not belong in this group + element_contribution_to_group = right_boundaries - base + element_contribution_to_group[element_contribution_to_group < 0] = 0 + + # if the element to the right is a full group size, this element is ruled out + rule_out = element_contribution_to_group[:, 1:] >= group_size + element_contribution_to_group[:, :-1][rule_out] = 0 + + # elements have a maximum value of 1 + element_contribution_to_group = np.fmin(element_contribution_to_group, 1) + + # if this element is already represented in the previous group (row), subtract that + element_contribution_to_group[1:] -= element_contribution_to_group[:-1] + element_contribution_to_group[element_contribution_to_group < 0] = 0 + + # element_contribution_to_group only represents non-nan elements + # insert into final including non-nan elements + final = np.full((n_groups, len(vector)), np.nan) + final[ + :, first_num_element : last_num_element + 1 + ] = element_contribution_to_group + return final + + horizontal_grouping_vectors = create_grouping_vector(data, 0, self.h_split) + vertical_grouping_vectors = create_grouping_vector(data, 1, self.v_split) + + matrices = [] + + for vertical, horizontal in itertools.product( + vertical_grouping_vectors, horizontal_grouping_vectors + ): + matrix = np.ones(data.shape) + matrix[np.isnan(data)] = np.nan + matrix *= horizontal + matrix *= vertical[:, None] # [:, None] converts to a column vector + + matrices.append(matrix) + + return matrices def matrix_layout(self): """Returns an array showing the layout of the matrices returned by `find_grid`.""" From 42599861c71e3a9154ef98c19659ab183e35076d Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 26 Sep 2023 10:05:27 +0200 Subject: [PATCH 23/55] Extend, update and improve documentation --- eitprocessing/roi_selection/gridselection.py | 98 +++++++++++++++----- 1 file changed, 75 insertions(+), 23 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 336345665..7f2f176c2 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -12,12 +12,33 @@ class GridSelection(ROISelection): """Create regions of interest by division into a grid. - GridSelection allows for the creation a list of matrices that can be used to divide a - two-dimensional array into several groups structured in a grid. An instance of GridSelection - contains information about how to subdivide an input matrix. Calling `find_grid(data)` results - in a list of matrices with the same dimension as `data`, each representing a single group. A - matrix contains the value False or 0 for pixels that do not belong to the group, and the value - True, 1 or any number between 0 and 1 for pixels that (partly) belong to the group. + GridSelection allows for the creation a list of 2D arrays that can be used to divide a two- or + higher-dimensional array into several regions structured in a grid. An instance of + GridSelection contains information about how to subdivide an input matrix. Calling + `find_grid(data)`, where data is a 2D array, results in a list of arrays with the same + dimension as `data`, each representing a single region. Each resulting 2D array contains the + value False or 0 for pixels that do not belong to the region, and the value True, 1 or any + number between 0 and 1 for pixels that (partly) belong to the region. + + Rows and columns at the edges of `data` that only contain NaN (not a number) values are + ignored. E.g. a (32, 32) array where the first and last two rows and first and last two columns + only contain NaN are split as if it is a (28, 28) array. The resulting arrays have the shape + (32, 32) with the same rows and columns only containing NaN values. + + If the number of rows or columns can not split evenly, a row or column can be split among two + regions. This behaviour is controlled by `split_pixels`. + + If `split_pixels` is `True`, e.g. a (2, 5) array that is split in two horizontal regions, the + first region will contain the first two columns, and half of the third column. The second + region contains half of the third columns, and the last column. + + If `split_pixels` is `False` (default), rows and columns will not be split. A warning will be + shown stating regions don't contain equal numbers of rows/columns. The regions towards the top + and left will be larger. E.g., when a (2, 5) array is split in two horizontal regions, the + first region will contain the first three columns, and the second region the last two columns. + + Regions are ordered according to C indexing order. The `matrix_layout()` method provides a map + showing how the regions are ordered. Common grids are pre-defined: - VentralAndDorsal: vertically divided into ventral and dorsal; @@ -27,20 +48,42 @@ class GridSelection(ROISelection): - Quadrants: vertically and horizontally divided into four quadrants. Args: - v_split: The number of vertical groups. Must be 1 or larger. - h_split: The number of horizontal groups. Must be 1 or larger. - split_pixels: Allows rows and columns to be split over two groups. Currently not - implemented. - - Example: - >>> gs = GridSelection(3, 1) - >>> matrices = gs.find_grid(included_pixels) - >>> sum_group1 = np.sum(matrices[0] * pixel_amplitude) - >>> print(gs.matrix_layout()) - array([[0], - [1], - [2]]) - + v_split: The number of vertical regions. Must be 1 or larger. + h_split: The number of horizontal regions. Must be 1 or larger. + split_pixels: Allows rows and columns to be split over two regions. + + Examples: + >>> pixel_map = array([[ 1, 2, 3], + [ 4, 5, 6], + [ 7, 8, 9], + [10, 11, 12], + [13, 14, 15], + [16, 17, 18]]) + >>> gs = GridSelection(3, 1, split_pixels=False) + >>> matrices = gs.find_grid(pixel_map) + >>> matrices[0] * pixel_map + array([[1, 2, 3], + [4, 5, 6], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0]]) + >>> gs.matrix_layout() + array([[0], + [1], + [2]]) + >>> gs2 = GridSelection(2, 2, split_pixels=True) + >>> matrices2 = gs.find_grid(pixel_map) + >>> gs2.matrix_layout() + array([[0, 1], + [2, 3]]) + >>> matrices2[2] + array([[0. , 0. , 0. ], + [0. , 0. , 0. ], + [0. , 0. , 0. ], + [1. , 0.5, 0. ], + [1. , 0.5, 0. ], + [1. , 0.5, 0. ]]) """ v_split: int @@ -112,7 +155,8 @@ def get_region_boundaries( if n_vectors_per_region % 1 > 0: warnings.warn( - f"The {orientation} groups will not have an equal number of {'columns' if horizontal else 'rows'}. " + f"The {orientation} regions will not have an equal number of " + f"{'columns' if horizontal else 'rows'}. " f"{n_vectors} is not equally divisible by {n_regions}.", RuntimeWarning, ) @@ -206,8 +250,8 @@ def create_grouping_vector(matrix, axis, n_groups): def matrix_layout(self): """Returns an array showing the layout of the matrices returned by `find_grid`.""" - n_groups = self.v_split * self.h_split - return np.reshape(np.arange(n_groups), (self.v_split, self.h_split)) + n_regions = self.v_split * self.h_split + return np.reshape(np.arange(n_regions), (self.v_split, self.h_split)) class InvalidDivision(Exception): @@ -224,6 +268,8 @@ class InvalidVerticalDivision(InvalidDivision): @dataclass class VentralAndDorsal(GridSelection): + """Split data into a ventral and dorsal region of interest.""" + v_split: Literal[2] = field(default=2, init=False) h_split: Literal[1] = field(default=1, init=False) split_pixels: bool = False @@ -231,6 +277,8 @@ class VentralAndDorsal(GridSelection): @dataclass class RightAndLeft(GridSelection): + """Split data into a right and left region of interest.""" + v_split: Literal[1] = field(default=1, init=False) h_split: Literal[2] = field(default=2, init=False) split_pixels: bool = False @@ -238,6 +286,8 @@ class RightAndLeft(GridSelection): @dataclass class FourLayers(GridSelection): + """Split data vertically into four layer regions of interest.""" + v_split: Literal[4] = field(default=4, init=False) h_split: Literal[1] = field(default=1, init=False) split_pixels: bool = False @@ -245,6 +295,8 @@ class FourLayers(GridSelection): @dataclass class Quadrants(GridSelection): + """Split data into four quadrant regions of interest.""" + v_split: Literal[2] = field(default=2, init=False) h_split: Literal[2] = field(default=2, init=False) split_pixels: bool = False From bed66d552c0e4fee14cd429ac951d7745eee8219 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 29 Sep 2023 10:47:49 +0200 Subject: [PATCH 24/55] Unify split pixels an non-split pixels function to reduce code reuse --- eitprocessing/roi_selection/gridselection.py | 236 +++++++++---------- 1 file changed, 116 insertions(+), 120 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 7f2f176c2..48db5e481 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -5,6 +5,7 @@ from dataclasses import field from typing import Literal import numpy as np +from numpy.typing import NDArray from . import ROISelection @@ -116,126 +117,21 @@ def __post_init__(self): ) def find_grid(self, data) -> list: - n_rows, n_columns = data.shape - - if self.split_pixels: - # TODO: create warning if number of regions > number of columns/rows - return self._find_grid_split_pixels(data) - - if self.h_split > n_columns: - raise InvalidHorizontalDivision( - f"`h_split` ({self.h_split}) is larger than the number of columns ({n_columns})." - ) - - if self.v_split > n_rows: - raise InvalidVerticalDivision( - f"`v_split` ({self.v_split}) is larger than the number or rows ({n_rows})." - ) - - return self._find_grid_no_split_pixels(data) - - def _find_grid_no_split_pixels(self, data): - # Detect upper, lower, left and right column of grid selection, - # i.e, find the first row and column where the sum of pixels in that column is not NaN - - is_numeric = ~np.isnan(data) - - def get_region_boundaries( - orientation: Literal["horizontal", "vertical"], n_regions: int - ): - horizontal = orientation == "horizontal" - axis = 0 if horizontal else 1 - vector_has_numeric_cells = is_numeric.sum(axis) > 0 - numeric_vector_indices = np.argwhere(vector_has_numeric_cells) - first_numeric_vector = numeric_vector_indices.min() - last_vector_numeric = numeric_vector_indices.max() - - n_vectors = last_vector_numeric - first_numeric_vector + 1 - n_vectors_per_region = n_vectors / n_regions - - if n_vectors_per_region % 1 > 0: - warnings.warn( - f"The {orientation} regions will not have an equal number of " - f"{'columns' if horizontal else 'rows'}. " - f"{n_vectors} is not equally divisible by {n_regions}.", - RuntimeWarning, - ) - - region_boundaries = [ - first_numeric_vector - + bisect.bisect_left(np.arange(n_vectors) / n_vectors_per_region, c) - for c in range(n_regions + 1) - ] - return region_boundaries - - h_boundaries = get_region_boundaries("horizontal", n_regions=self.h_split) - v_boundaries = get_region_boundaries("vertical", n_regions=self.v_split) + function = ( + self._create_grouping_vector_split_pixels + if self.split_pixels + else self._create_grouping_vector_no_split_pixels + ) + horizontal_grouping_vectors = function(data, "horizontal", self.h_split) + + function = ( + self._create_grouping_vector_split_pixels + if self.split_pixels + else self._create_grouping_vector_no_split_pixels + ) + vertical_grouping_vectors = function(data, "vertical", self.v_split) matrices = [] - for v_start, v_end in itertools.pairwise(v_boundaries): - for h_start, h_end in itertools.pairwise(h_boundaries): - matrix = np.copy(is_numeric) - matrix[:, :h_start] = False - matrix[:, h_end:] = False - matrix[:v_start, :] = False - matrix[v_end:, :] = False - matrices.append(matrix) - - return matrices - - def _find_grid_split_pixels(self, data): - def create_grouping_vector(matrix, axis, n_groups): - """Create a grouping vector to split vector into `n` groups.""" - - # create a vector that is nan if the entire column/row is nan, 1 otherwise - nan = np.all(np.isnan(matrix), axis=axis) - vector = np.ones(nan.shape) - vector[nan] = np.nan - - # remove non-numeric (nan) elements at vector ends - # nan elements between numeric elements are kept - numeric_element_indices = np.argwhere(~np.isnan(vector)) - first_num_element = numeric_element_indices.min() - last_num_element = numeric_element_indices.max() - n_elements = last_num_element - first_num_element + 1 - - group_size = n_elements / n_groups - - # find the right boundaries (upper values) of each group - right_boundaries = (np.arange(n_groups) + 1) * group_size - right_boundaries = right_boundaries[:, None] # converts it to a row vector - - # each row in the base represents one group - base = np.tile(np.arange(n_elements), (n_groups, 1)) - - # if the element number is higher than the split, it does not belong in this group - element_contribution_to_group = right_boundaries - base - element_contribution_to_group[element_contribution_to_group < 0] = 0 - - # if the element to the right is a full group size, this element is ruled out - rule_out = element_contribution_to_group[:, 1:] >= group_size - element_contribution_to_group[:, :-1][rule_out] = 0 - - # elements have a maximum value of 1 - element_contribution_to_group = np.fmin(element_contribution_to_group, 1) - - # if this element is already represented in the previous group (row), subtract that - element_contribution_to_group[1:] -= element_contribution_to_group[:-1] - element_contribution_to_group[element_contribution_to_group < 0] = 0 - - # element_contribution_to_group only represents non-nan elements - # insert into final including non-nan elements - final = np.full((n_groups, len(vector)), np.nan) - final[ - :, first_num_element : last_num_element + 1 - ] = element_contribution_to_group - return final - - horizontal_grouping_vectors = create_grouping_vector(data, 0, self.h_split) - vertical_grouping_vectors = create_grouping_vector(data, 1, self.v_split) - - matrices = [] - for vertical, horizontal in itertools.product( vertical_grouping_vectors, horizontal_grouping_vectors ): @@ -243,12 +139,112 @@ def create_grouping_vector(matrix, axis, n_groups): matrix[np.isnan(data)] = np.nan matrix *= horizontal matrix *= vertical[:, None] # [:, None] converts to a column vector - matrices.append(matrix) return matrices - def matrix_layout(self): + @staticmethod + def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals + data: NDArray, orientation: Literal["horizontal", "vertical"], n_regions: int + ) -> list[NDArray]: + is_numeric = ~np.isnan(data) + horizontal = orientation == "horizontal" + axis = 0 if horizontal else 1 + numeric_vector_indices = np.argwhere(is_numeric.sum(axis) > 0) + first_numeric_vector = numeric_vector_indices.min() + last_vector_numeric = numeric_vector_indices.max() + + n_vectors = last_vector_numeric - first_numeric_vector + 1 + + if n_regions > n_vectors: + if horizontal: # pylint: disable=no-else-raise + raise InvalidHorizontalDivision( + f"The number horizontal regions is larger than the " + f"number of available columns ({n_vectors})." + ) + else: + raise InvalidVerticalDivision( + f"The number vertical regions is larger than the " + f"number of available rows ({n_vectors})." + ) + + n_vectors_per_region = n_vectors / n_regions + + if n_vectors_per_region % 1 > 0: + warnings.warn( + f"The {orientation} regions will not have an equal number of " + f"{'columns' if horizontal else 'rows'}. " + f"{n_vectors} is not equally divisible by {n_regions}.", + RuntimeWarning, + ) + + region_boundaries = [ + first_numeric_vector + + bisect.bisect_left(np.arange(n_vectors) / n_vectors_per_region, c) + for c in range(n_regions + 1) + ] + + vectors = [] + for start, end in itertools.pairwise(region_boundaries): + vector = np.ones(data.shape[1 - axis]) + vector[:start] = 0 + vector[end:] = 0 + vectors.append(vector) + + return vectors + + @staticmethod + def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals + matrix: NDArray, orientation: Literal["horizontal", "vertical"], n_groups: int + ) -> NDArray: + """Create a grouping vector to split vector into `n` groups.""" + axis = 0 if orientation == "horizontal" else 1 + + # create a vector that is nan if the entire column/row is nan, 1 otherwise + vector_is_nan = np.all(np.isnan(matrix), axis=axis) + vector = np.ones(vector_is_nan.shape) + vector[vector_is_nan] = np.nan + + # remove non-numeric (nan) elements at vector ends + # nan elements between numeric elements are kept + numeric_element_indices = np.argwhere(~np.isnan(vector)) + first_num_element = numeric_element_indices.min() + last_num_element = numeric_element_indices.max() + n_elements = last_num_element - first_num_element + 1 + + group_size = n_elements / n_groups + + # find the right boundaries (upper values) of each group + right_boundaries = (np.arange(n_groups) + 1) * group_size + right_boundaries = right_boundaries[:, None] # converts it to a row vector + + # each row in the base represents one group + base = np.tile(np.arange(n_elements), (n_groups, 1)) + + # if the element number is higher than the split, it does not belong in this group + element_contribution_to_group = right_boundaries - base + element_contribution_to_group[element_contribution_to_group < 0] = 0 + + # if the element to the right is a full group size, this element is ruled out + rule_out = element_contribution_to_group[:, 1:] >= group_size + element_contribution_to_group[:, :-1][rule_out] = 0 + + # elements have a maximum value of 1 + element_contribution_to_group = np.fmin(element_contribution_to_group, 1) + + # if this element is already represented in the previous group (row), subtract that + element_contribution_to_group[1:] -= element_contribution_to_group[:-1] + element_contribution_to_group[element_contribution_to_group < 0] = 0 + + # element_contribution_to_group only represents non-nan elements + # insert into final including non-nan elements + final = np.full((n_groups, len(vector)), np.nan) + final[ + :, first_num_element : last_num_element + 1 + ] = element_contribution_to_group + return final + + def matrix_layout(self) -> NDArray: """Returns an array showing the layout of the matrices returned by `find_grid`.""" n_regions = self.v_split * self.h_split return np.reshape(np.arange(n_regions), (self.v_split, self.h_split)) From cfa97cea82406919347136f936cb4383e697660c Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 29 Sep 2023 10:47:44 +0200 Subject: [PATCH 25/55] Update tests to reflect split pixels and unification --- tests/test_gridselection.py | 39 +++++++++++++------------------------ 1 file changed, 14 insertions(+), 25 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index c43cfd515..b134a3de3 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -69,7 +69,6 @@ def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray] "v_split,h_split,split_pixels,exception_type", [ (1, 1, False, None), - (1, 1, True, NotImplementedError), (0, 1, False, InvalidVerticalDivision), (-1, 1, False, InvalidVerticalDivision), (1.1, 1, False, TypeError), @@ -139,34 +138,36 @@ def test_no_split_pixels_no_nans(shape, split_vh, result_string): ( "NNN,RRR,RRR,RRR,RRR", (2, 2), - "FFF,TTF,TTF,FFF,FFF;" - "FFF,FFT,FFT,FFF,FFF;" - "FFF,FFF,FFF,TTF,TTF;" - "FFF,FFF,FFF,FFT,FFT", + "NNN,TTF,TTF,FFF,FFF;" + "NNN,FFT,FFT,FFF,FFF;" + "NNN,FFF,FFF,TTF,TTF;" + "NNN,FFF,FFF,FFT,FFT", ), ( "NNNNNN,NNNNNN,NRRRRR,RNRRRR,NNNNNN", (2, 2), - "FFFFFF,FFFFFF,FTTFFF,FFFFFF,FFFFFF;" - "FFFFFF,FFFFFF,FFFTTT,FFFFFF,FFFFFF;" - "FFFFFF,FFFFFF,FFFFFF,TFTFFF,FFFFFF;" - "FFFFFF,FFFFFF,FFFFFF,FFFTTT,FFFFFF", + "NNNNNN,NNNNNN,NTTFFF,FNFFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFTTT,FNFFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFFFF,TNTFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFFFF,FNFTTT,NNNNNN", ), ], ) def test_no_split_pixels_nans(data_string, split_vh, result_string): data = matrices_from_string(data_string)[0] - result = matrices_from_string(result_string, boolean=True) + numeric_values = np.ones(data.shape) + numeric_values[np.isnan(data)] = np.nan + result = matrices_from_string(result_string, boolean=False) v_split, h_split = split_vh - gs = GridSelection(v_split, h_split) + gs = GridSelection(v_split, h_split, split_pixels=False) matrices = gs.find_grid(data) num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) assert len(matrices) == h_split * v_split - assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) - assert np.array_equal(matrices, result) + assert np.array_equal(num_appearances, numeric_values, equal_nan=True) + assert np.array_equal(matrices, result, equal_nan=True) @pytest.mark.parametrize( @@ -178,7 +179,6 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): ("RRRR,RRRR", (1, 3), RuntimeWarning), ("RR,RR,RR", (2, 1), RuntimeWarning), ("RR,RR,RR", (3, 1), None), - ("NN,RR,RR", (3, 1), RuntimeWarning), ("NN,RR,RR", (2, 1), None), ], ) @@ -218,17 +218,6 @@ def test_exceptions(data_string, split_vh, exception_type): gs.find_grid(data) -def test_split_pixels(): - with pytest.raises(NotImplementedError): - gs = GridSelection(1, 1, True) - - gs = GridSelection(1, 1, False) - gs.split_pixels = True - data = np.ones((2, 2)) - with pytest.raises(NotImplementedError): - gs.find_grid(data) - - @pytest.mark.parametrize( "split_vh,result", [ From bb65617f2643e9966eec2d1f74c484eba6dade2a Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:34:35 +0200 Subject: [PATCH 26/55] Create module-specific warnings for catching in a GUI --- eitprocessing/roi_selection/gridselection.py | 35 +++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 48db5e481..525b9f0a9 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -148,8 +148,7 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals data: NDArray, orientation: Literal["horizontal", "vertical"], n_regions: int ) -> list[NDArray]: is_numeric = ~np.isnan(data) - horizontal = orientation == "horizontal" - axis = 0 if horizontal else 1 + axis = 0 if orientation == "horizontal" else 1 numeric_vector_indices = np.argwhere(is_numeric.sum(axis) > 0) first_numeric_vector = numeric_vector_indices.min() last_vector_numeric = numeric_vector_indices.max() @@ -157,7 +156,7 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals n_vectors = last_vector_numeric - first_numeric_vector + 1 if n_regions > n_vectors: - if horizontal: # pylint: disable=no-else-raise + if orientation == "horizontal": # pylint: disable=no-else-raise raise InvalidHorizontalDivision( f"The number horizontal regions is larger than the " f"number of available columns ({n_vectors})." @@ -171,12 +170,18 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals n_vectors_per_region = n_vectors / n_regions if n_vectors_per_region % 1 > 0: - warnings.warn( - f"The {orientation} regions will not have an equal number of " - f"{'columns' if horizontal else 'rows'}. " - f"{n_vectors} is not equally divisible by {n_regions}.", - RuntimeWarning, - ) + if orientation == "horizontal": + warnings.warn( + f"The horizontal regions will not have an equal number of " + f"columns. {n_vectors} is not equally divisible by {n_regions}.", + UnevenHorizontalDivision, + ) + else: + warnings.warn( + f"The vertical regions will not have an equal number of " + f"columns. {n_vectors} is not equally divisible by {n_regions}.", + UnevenVerticalDivision, + ) region_boundaries = [ first_numeric_vector @@ -262,6 +267,18 @@ class InvalidVerticalDivision(InvalidDivision): """Raised when the data can't be divided into vertical regions.""" +class UnevenDivision(Warning): + """Warning for when a grid selection results in groups of uneven size.""" + + +class UnevenHorizontalDivision(UnevenDivision): + """Warning for when a grid selection results in horizontal groups of uneven size.""" + + +class UnevenVerticalDivision(UnevenDivision): + """Warning for when a grid selection results in vertical groups of uneven size.""" + + @dataclass class VentralAndDorsal(GridSelection): """Split data into a ventral and dorsal region of interest.""" From 37b3c9bdc32a671d175197365259f8f3401ec3b3 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:35:01 +0200 Subject: [PATCH 27/55] Update documentation, removing True/False values --- eitprocessing/roi_selection/gridselection.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 525b9f0a9..36269869a 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -18,26 +18,26 @@ class GridSelection(ROISelection): GridSelection contains information about how to subdivide an input matrix. Calling `find_grid(data)`, where data is a 2D array, results in a list of arrays with the same dimension as `data`, each representing a single region. Each resulting 2D array contains the - value False or 0 for pixels that do not belong to the region, and the value True, 1 or any - number between 0 and 1 for pixels that (partly) belong to the region. + value 0 for pixels that do not belong to the region, and the value 1 or any number between 0 + and 1 for pixels that (partly) belong to the region. Rows and columns at the edges of `data` that only contain NaN (not a number) values are ignored. E.g. a (32, 32) array where the first and last two rows and first and last two columns only contain NaN are split as if it is a (28, 28) array. The resulting arrays have the shape - (32, 32) with the same rows and columns only containing NaN values. + (32, 32) with the same cells as the input data containing NaN values. If the number of rows or columns can not split evenly, a row or column can be split among two regions. This behaviour is controlled by `split_pixels`. - If `split_pixels` is `True`, e.g. a (2, 5) array that is split in two horizontal regions, the - first region will contain the first two columns, and half of the third column. The second - region contains half of the third columns, and the last column. - If `split_pixels` is `False` (default), rows and columns will not be split. A warning will be shown stating regions don't contain equal numbers of rows/columns. The regions towards the top and left will be larger. E.g., when a (2, 5) array is split in two horizontal regions, the first region will contain the first three columns, and the second region the last two columns. + If `split_pixels` is `True`, e.g. a (2, 5) array that is split in two horizontal regions, the + first region will contain the first two columns, and half of the third column. The second + region contains half of the third columns, and the last column. + Regions are ordered according to C indexing order. The `matrix_layout()` method provides a map showing how the regions are ordered. From 451880e2c5fd5cc358978c46839094c9381ef513 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:36:08 +0200 Subject: [PATCH 28/55] Add warnings for when there are more groups than vectors --- eitprocessing/roi_selection/gridselection.py | 32 +++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 36269869a..d08952d22 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -219,6 +219,20 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals group_size = n_elements / n_groups + if group_size < 1: + if orientation == "horizontal": # pylint: disable=no-else-raise + warnings.warn( + f"The number horizontal regions ({n_groups}) is larger than the " + f"number of available columns ({n_elements}).", + MoreHorizontalGroupsThanColumns, + ) + else: + warnings.warn( + f"The number vertical regions ({n_groups}) is larger than the " + f"number of available rows ({n_elements}).", + MoreVerticalGroupsThanRows, + ) + # find the right boundaries (upper values) of each group right_boundaries = (np.arange(n_groups) + 1) * group_size right_boundaries = right_boundaries[:, None] # converts it to a row vector @@ -267,7 +281,11 @@ class InvalidVerticalDivision(InvalidDivision): """Raised when the data can't be divided into vertical regions.""" -class UnevenDivision(Warning): +class DivisionWarning(Warning): + pass + + +class UnevenDivision(DivisionWarning): """Warning for when a grid selection results in groups of uneven size.""" @@ -279,6 +297,18 @@ class UnevenVerticalDivision(UnevenDivision): """Warning for when a grid selection results in vertical groups of uneven size.""" +class MoreGroupsThanVectors(DivisionWarning): + """Warning for when the groups outnumber the available vectors.""" + + +class MoreVerticalGroupsThanRows(MoreGroupsThanVectors): + """Warning for when the vertical groups outnumber the available rows.""" + + +class MoreHorizontalGroupsThanColumns(MoreGroupsThanVectors): + """Warning for when the horizontal groups outnumber the available rows.""" + + @dataclass class VentralAndDorsal(GridSelection): """Split data into a ventral and dorsal region of interest.""" From 6a4aa95fb29b5fabbf0971f958a65b52717300df Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:36:48 +0200 Subject: [PATCH 29/55] Allow orientation specific splitting This allows e.g. vertical regions to be split by row, by horizontal regions not be split by column. --- eitprocessing/roi_selection/gridselection.py | 31 +++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index d08952d22..729bad950 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -1,6 +1,7 @@ import bisect import itertools import warnings +from dataclasses import InitVar from dataclasses import dataclass from dataclasses import field from typing import Literal @@ -89,9 +90,11 @@ class GridSelection(ROISelection): v_split: int h_split: int - split_pixels: bool = False + split_pixels: InitVar[bool | None] = None + split_rows: bool | None = None + split_cols: bool | None = None - def __post_init__(self): + def __post_init__(self, split_pixels): if not isinstance(self.v_split, int): raise TypeError( "Invalid type for `h_split`. " @@ -110,23 +113,31 @@ def __post_init__(self): if self.h_split < 1: raise InvalidHorizontalDivision("`h_split` can't be smaller than 1.") - if not isinstance(self.split_pixels, bool): - raise TypeError( - "Invalid type for `split_pixels`. " - f"Should be `bool`, not {type(self.split_pixels)}" - ) + if split_pixels is not None: + if not isinstance(split_pixels, bool): + raise TypeError( + "Invalid type for `split_pixels`. " + f"Should be `bool` or `NoneType`, not {type(split_pixels)}." + ) + + if self.split_rows is not None or self.split_cols is not None: + raise AttributeError( + "Don't provide both `split_pixels` and either of `split_rows` and `split_columns`." + ) + + self.split_cols = self.split_rows = split_pixels - def find_grid(self, data) -> list: + def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels - if self.split_pixels + if self.split_rows else self._create_grouping_vector_no_split_pixels ) horizontal_grouping_vectors = function(data, "horizontal", self.h_split) function = ( self._create_grouping_vector_split_pixels - if self.split_pixels + if self.split_cols else self._create_grouping_vector_no_split_pixels ) vertical_grouping_vectors = function(data, "vertical", self.v_split) From 7a7d0d5a175af442234b9e37e6ffb55ca2449e7d Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:39:07 +0200 Subject: [PATCH 30/55] Add tests for when split_pixels is True --- tests/test_gridselection.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index b134a3de3..6a219856c 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -131,6 +131,44 @@ def test_no_split_pixels_no_nans(shape, split_vh, result_string): assert np.array_equal(matrices, result_matrices) +@pytest.mark.parametrize( + "shape,split_vh,result", + [ + ( + (2, 3), + (1, 2), + [[[1.0, 0.5, 0], [1.0, 0.5, 0]], [[0, 0.5, 1.0], [0, 0.5, 1.0]]], + ), + ( + (3, 2), + (2, 1), + [[[1.0, 1.0], [0.5, 0.5], [0, 0]], [[0, 0], [0.5, 0.5], [1, 1]]], + ), + ( + (1, 4), + (1, 3), + [ + [[1.0, 1 / 3, 0.0, 0.0]], + [[0.0, 2 / 3, 2 / 3, 0.0]], + [[0.0, 0.0, 1 / 3, 1.0]], + ], + ), + ], +) +def test_split_pixels_no_nans(shape, split_vh, result): + data = np.random.default_rng().integers(1, 100, shape) + expected_result = [np.array(r) for r in result] + + gs = GridSelection(*split_vh, split_pixels=True) + actual_result = gs.find_grid(data) + + num_appearances = np.sum(np.stack(actual_result, axis=-1), axis=-1) + + assert len(actual_result) == np.prod(split_vh) + assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) + assert np.allclose(actual_result, expected_result) + + @pytest.mark.parametrize( "data_string,split_vh,result_string", [ From ffce6d16486b5ce46aabc1e060844b4f087b0f06 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 14:39:50 +0200 Subject: [PATCH 31/55] Update tests, removing True/False values --- tests/test_gridselection.py | 58 ++++++++++++++----------------------- 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 6a219856c..3753b5741 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -7,30 +7,25 @@ from eitprocessing.roi_selection.gridselection import InvalidVerticalDivision -def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray]: - """Generates a list of matrices from a string containing a matrix representation. +def matrices_from_string(string: str) -> list[np.ndarray]: + """Generates a list of matrices from a string containing a representation of matrices. - A matrix represtation contains one character per cell that describes the value of that cell. + A represtation of matrices contains one character per cell that describes the value of that cell. Rows are delimited by commas. Matrices are delimited by semi-colons. - The returned matrices by default have `np.floating` as dtype. When `boolean` is set to True, the - dtype is `bool`. That means that the actual values in the matrices depend on the value of `boolean`. - - The following characters are transformed to these corresponding values in either `np.floating` or - `bool` mode: - - T -> 1. or True - - F -> 0. or False - - 1 -> 1. or True - - N -> np.nan or False - - R -> np.random.int(1, 100) or True + The following characters are transformed to these corresponding values: + - T / 1 -> 1 + - F / 0 -> 0 + - R -> np.random.int(2, 100) + - N / any other character -> np.nan Examples: >>> matrices_from_string("1T1,FNR") [array([[ 1., 1., 1.], [ 0., nan, 40.]])] - >>> matrices_from_string("1T1,FNR", boolean=True) - [array([[ True, True, True], - [False, False, True]])] + >>> matrices_from_string("1T1,FNR") + [array([[ 1., 1., 1.], + [ 0., nan, 37]])] >>> matrices_from_string("RR,RR;RRR;1R") [array([[21., 80.], [43., 10.]]), @@ -41,25 +36,16 @@ def matrices_from_string(string: str, boolean: bool = False) -> list[np.ndarray] matrices = [] for part in string.split(";"): str_matrix = np.array([tuple(row) for row in part.split(",")], dtype="object") - if boolean: - matrix = np.full(str_matrix.shape, False, dtype=bool) - matrix[np.nonzero(str_matrix == "N")] = False - matrix[np.nonzero(str_matrix == "1")] = True - matrix[np.nonzero(str_matrix == "R")] = True - - else: - matrix = np.full(str_matrix.shape, np.nan, dtype=np.floating) - matrix[np.nonzero(str_matrix == "N")] = np.nan - matrix[np.nonzero(str_matrix == "1")] = 1 - matrix = np.where( - str_matrix == "R", - np.random.default_rng().integers(1, 100, matrix.shape), - matrix, - ) - - matrix[np.nonzero(str_matrix == "T")] = True - matrix[np.nonzero(str_matrix == "F")] = False - + matrix = np.full(str_matrix.shape, np.nan, dtype=np.floating) + matrix[np.nonzero(str_matrix == "1")] = 1 + matrix[np.nonzero(str_matrix == "T")] = 1 + matrix[np.nonzero(str_matrix == "0")] = 0 + matrix[np.nonzero(str_matrix == "F")] = 0 + matrix = np.where( + str_matrix == "R", + np.random.default_rng().integers(2, 100, matrix.shape), + matrix, + ) matrices.append(matrix) return matrices @@ -195,7 +181,7 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): data = matrices_from_string(data_string)[0] numeric_values = np.ones(data.shape) numeric_values[np.isnan(data)] = np.nan - result = matrices_from_string(result_string, boolean=False) + result = matrices_from_string(result_string) v_split, h_split = split_vh gs = GridSelection(v_split, h_split, split_pixels=False) From 4965ba2301c671fe6e320c77580b4f34dcd30047 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 09:03:51 +0200 Subject: [PATCH 32/55] Update tests for new warning types --- tests/test_gridselection.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 3753b5741..cdfd12430 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -5,6 +5,10 @@ from eitprocessing.roi_selection.gridselection import InvalidDivision from eitprocessing.roi_selection.gridselection import InvalidHorizontalDivision from eitprocessing.roi_selection.gridselection import InvalidVerticalDivision +from eitprocessing.roi_selection.gridselection import MoreHorizontalGroupsThanColumns +from eitprocessing.roi_selection.gridselection import MoreVerticalGroupsThanRows +from eitprocessing.roi_selection.gridselection import UnevenHorizontalDivision +from eitprocessing.roi_selection.gridselection import UnevenVerticalDivision def matrices_from_string(string: str) -> list[np.ndarray]: @@ -195,20 +199,23 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): @pytest.mark.parametrize( - "data_string,split_vh,warning_type", + "data_string,split_vh,split_rows,split_columns,warning_type", [ - ("RR,RR", (2, 2), None), - ("RRR,RRR", (2, 2), RuntimeWarning), - ("RRR,RRR", (1, 3), None), - ("RRRR,RRRR", (1, 3), RuntimeWarning), - ("RR,RR,RR", (2, 1), RuntimeWarning), - ("RR,RR,RR", (3, 1), None), - ("NN,RR,RR", (2, 1), None), + ("RR,RR", (2, 2), False, False, None), + ("RRR,RRR", (2, 2), False, False, UnevenHorizontalDivision), + ("RRR,RRR", (1, 3), False, False, None), + ("RRRR,RRRR", (1, 3), False, False, UnevenHorizontalDivision), + ("RR,RR,RR", (2, 1), False, False, UnevenVerticalDivision), + ("RR,RR,RR", (3, 1), False, False, None), + ("NN,RR,RR", (2, 1), False, False, None), + ("R", (2, 1), True, True, MoreVerticalGroupsThanRows), + ("R", (1, 2), True, True, MoreHorizontalGroupsThanColumns), + ("RRR,RRR,RRR", (4, 3), True, True, MoreVerticalGroupsThanRows), ], ) -def test_warnings(data_string, split_vh, warning_type): +def test_warnings(data_string, split_vh, split_rows, split_columns, warning_type): data = matrices_from_string(data_string)[0] - gs = GridSelection(*split_vh) + gs = GridSelection(*split_vh, split_rows=split_rows, split_cols=split_columns) if warning_type is None: # catch all warnings and raises them From 972b7067c2928d1274098d1a985b6a8d66235201 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 09:03:07 +0200 Subject: [PATCH 33/55] Remove `split_pixels` default value from convenience classes --- eitprocessing/roi_selection/gridselection.py | 22 +++++++++----------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 729bad950..c609085b8 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -28,16 +28,18 @@ class GridSelection(ROISelection): (32, 32) with the same cells as the input data containing NaN values. If the number of rows or columns can not split evenly, a row or column can be split among two - regions. This behaviour is controlled by `split_pixels`. + regions. This behaviour is controlled by `split_rows` and `split_columns`. - If `split_pixels` is `False` (default), rows and columns will not be split. A warning will be - shown stating regions don't contain equal numbers of rows/columns. The regions towards the top - and left will be larger. E.g., when a (2, 5) array is split in two horizontal regions, the - first region will contain the first three columns, and the second region the last two columns. + If `split_rows` is `False` (default), rows will not be split between two groups. A warning will + be shown stating regions don't contain equal numbers of rows. The regions towards the top will + be larger. E.g., when a (5, 2) array is split in two vertical regions, the first region will + contain the first three rows, and the second region the last two rows. - If `split_pixels` is `True`, e.g. a (2, 5) array that is split in two horizontal regions, the - first region will contain the first two columns, and half of the third column. The second - region contains half of the third columns, and the last column. + If `split_rows` is `True`, e.g. a (5, 2) array that is split in two vertical regions, the first + region will contain the first two rows and half of each pixel of the third row. The second + region contains half of each pixel in the third row, and the last two rows. + + `split_columns` has the same effect on columns as `split_rows` has on rows. Regions are ordered according to C indexing order. The `matrix_layout()` method provides a map showing how the regions are ordered. @@ -326,7 +328,6 @@ class VentralAndDorsal(GridSelection): v_split: Literal[2] = field(default=2, init=False) h_split: Literal[1] = field(default=1, init=False) - split_pixels: bool = False @dataclass @@ -335,7 +336,6 @@ class RightAndLeft(GridSelection): v_split: Literal[1] = field(default=1, init=False) h_split: Literal[2] = field(default=2, init=False) - split_pixels: bool = False @dataclass @@ -344,7 +344,6 @@ class FourLayers(GridSelection): v_split: Literal[4] = field(default=4, init=False) h_split: Literal[1] = field(default=1, init=False) - split_pixels: bool = False @dataclass @@ -353,4 +352,3 @@ class Quadrants(GridSelection): v_split: Literal[2] = field(default=2, init=False) h_split: Literal[2] = field(default=2, init=False) - split_pixels: bool = False From 695279a2b9e6f2d8083b843373e99a52e2667725 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 19:59:52 +0200 Subject: [PATCH 34/55] Add comment about floating point imprecision --- tests/test_gridselection.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index cdfd12430..4a0f2181e 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -156,6 +156,9 @@ def test_split_pixels_no_nans(shape, split_vh, result): assert len(actual_result) == np.prod(split_vh) assert np.array_equal(num_appearances, (~np.isnan(data) * 1)) + + # Ideally, we'd use np.array_equal() here, but due to floating point arithmetic, they values + # are off by an insignificant amount. assert np.allclose(actual_result, expected_result) From ea6fa2d9f9002f070681ea738fc947504a85203f Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:21:48 +0200 Subject: [PATCH 35/55] Remove `split_pixels` argument altogether --- eitprocessing/roi_selection/gridselection.py | 24 ++++---------------- tests/test_gridselection.py | 14 ++++++++---- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index c609085b8..189952ded 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -54,7 +54,8 @@ class GridSelection(ROISelection): Args: v_split: The number of vertical regions. Must be 1 or larger. h_split: The number of horizontal regions. Must be 1 or larger. - split_pixels: Allows rows and columns to be split over two regions. + split_rows: Allows rows to be split over two regions. + split_columns: Allows columns to be split over two regions. Examples: >>> pixel_map = array([[ 1, 2, 3], @@ -92,11 +93,10 @@ class GridSelection(ROISelection): v_split: int h_split: int - split_pixels: InitVar[bool | None] = None - split_rows: bool | None = None - split_cols: bool | None = None + split_rows: bool = False + split_cols: bool = False - def __post_init__(self, split_pixels): + def __post_init__(self): if not isinstance(self.v_split, int): raise TypeError( "Invalid type for `h_split`. " @@ -115,20 +115,6 @@ def __post_init__(self, split_pixels): if self.h_split < 1: raise InvalidHorizontalDivision("`h_split` can't be smaller than 1.") - if split_pixels is not None: - if not isinstance(split_pixels, bool): - raise TypeError( - "Invalid type for `split_pixels`. " - f"Should be `bool` or `NoneType`, not {type(split_pixels)}." - ) - - if self.split_rows is not None or self.split_cols is not None: - raise AttributeError( - "Don't provide both `split_pixels` and either of `split_rows` and `split_columns`." - ) - - self.split_cols = self.split_rows = split_pixels - def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 4a0f2181e..14549f047 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -72,11 +72,15 @@ def matrices_from_string(string: str) -> list[np.ndarray]: ) def test_initialisation(v_split, h_split, split_pixels, exception_type): if exception_type is None: - GridSelection(v_split, h_split, split_pixels) + GridSelection( + v_split, h_split, split_columns=split_pixels, split_rows=split_pixels + ) else: with pytest.raises(exception_type): - GridSelection(v_split, h_split, split_pixels) + GridSelection( + v_split, h_split, split_columns=split_pixels, split_rows=split_pixels + ) @pytest.mark.parametrize( @@ -149,7 +153,7 @@ def test_split_pixels_no_nans(shape, split_vh, result): data = np.random.default_rng().integers(1, 100, shape) expected_result = [np.array(r) for r in result] - gs = GridSelection(*split_vh, split_pixels=True) + gs = GridSelection(*split_vh, split_rows=True, split_columns=True) actual_result = gs.find_grid(data) num_appearances = np.sum(np.stack(actual_result, axis=-1), axis=-1) @@ -191,7 +195,7 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): result = matrices_from_string(result_string) v_split, h_split = split_vh - gs = GridSelection(v_split, h_split, split_pixels=False) + gs = GridSelection(v_split, h_split, split_rows=False, split_columns=False) matrices = gs.find_grid(data) num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) @@ -218,7 +222,7 @@ def test_no_split_pixels_nans(data_string, split_vh, result_string): ) def test_warnings(data_string, split_vh, split_rows, split_columns, warning_type): data = matrices_from_string(data_string)[0] - gs = GridSelection(*split_vh, split_rows=split_rows, split_cols=split_columns) + gs = GridSelection(*split_vh, split_rows=split_rows, split_columns=split_columns) if warning_type is None: # catch all warnings and raises them From 1434f7c3cf84e84ba857b4762b47eb746cbffe7d Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:21:18 +0200 Subject: [PATCH 36/55] Rename split_cols to split_columns --- eitprocessing/roi_selection/gridselection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 189952ded..46f4a9262 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -94,7 +94,7 @@ class GridSelection(ROISelection): v_split: int h_split: int split_rows: bool = False - split_cols: bool = False + split_columns: bool = False def __post_init__(self): if not isinstance(self.v_split, int): @@ -125,7 +125,7 @@ def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels - if self.split_cols + if self.split_columns else self._create_grouping_vector_no_split_pixels ) vertical_grouping_vectors = function(data, "vertical", self.v_split) From a713a85d87e5e5decc2dfd2f138e004cc46c75e7 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:21:37 +0200 Subject: [PATCH 37/55] Add default split arguments to convenience classes --- eitprocessing/roi_selection/gridselection.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 46f4a9262..118e9b28b 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -314,6 +314,7 @@ class VentralAndDorsal(GridSelection): v_split: Literal[2] = field(default=2, init=False) h_split: Literal[1] = field(default=1, init=False) + split_rows = True @dataclass @@ -322,6 +323,7 @@ class RightAndLeft(GridSelection): v_split: Literal[1] = field(default=1, init=False) h_split: Literal[2] = field(default=2, init=False) + split_columns = False @dataclass @@ -330,6 +332,7 @@ class FourLayers(GridSelection): v_split: Literal[4] = field(default=4, init=False) h_split: Literal[1] = field(default=1, init=False) + split_rows = True @dataclass @@ -338,3 +341,5 @@ class Quadrants(GridSelection): v_split: Literal[2] = field(default=2, init=False) h_split: Literal[2] = field(default=2, init=False) + split_columns = False + split_rows = True From b8faa9102f74908354c8ed5f6f805dd93959a5e8 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:22:41 +0200 Subject: [PATCH 38/55] Re-add checks for split arguments --- eitprocessing/roi_selection/gridselection.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 118e9b28b..b9530d0b2 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -115,6 +115,18 @@ def __post_init__(self): if self.h_split < 1: raise InvalidHorizontalDivision("`h_split` can't be smaller than 1.") + if not isinstance(self.split_columns, bool): + raise TypeError( + "Invalid type for `split_columns`. " + f"Should be bool, not {type(self.h_split)}." + ) + + if not isinstance(self.split_rows, bool): + raise TypeError( + "Invalid type for `split_rows`. " + f"Should be bool, not {type(self.h_split)}." + ) + def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels From 6e860463d550f7d1946ab17f75fe49469ba7a953 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:27:09 +0200 Subject: [PATCH 39/55] Add option to ignore or include nan rows/columns --- eitprocessing/roi_selection/gridselection.py | 55 ++++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index b9530d0b2..5431657e6 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -95,6 +95,8 @@ class GridSelection(ROISelection): h_split: int split_rows: bool = False split_columns: bool = False + ignore_nan_rows: bool = True + ignore_nan_columns: bool = True def __post_init__(self): if not isinstance(self.v_split, int): @@ -154,15 +156,24 @@ def find_grid(self, data) -> list[NDArray]: return matrices - @staticmethod def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals - data: NDArray, orientation: Literal["horizontal", "vertical"], n_regions: int + self, + data: NDArray, + orientation: Literal["horizontal", "vertical"], + n_regions: int, ) -> list[NDArray]: - is_numeric = ~np.isnan(data) axis = 0 if orientation == "horizontal" else 1 - numeric_vector_indices = np.argwhere(is_numeric.sum(axis) > 0) - first_numeric_vector = numeric_vector_indices.min() - last_vector_numeric = numeric_vector_indices.max() + + if (orientation == "horizontal" and self.ignore_nan_columns) or ( + orientation == "vertical" and self.ignore_nan_rows + ): + is_numeric = ~np.isnan(data) + numeric_vector_indices = np.argwhere(is_numeric.sum(axis) > 0) + first_numeric_vector = numeric_vector_indices.min() + last_vector_numeric = numeric_vector_indices.max() + else: + first_numeric_vector = 0 + last_vector_numeric = data.shape[1 - axis] - 1 n_vectors = last_vector_numeric - first_numeric_vector + 1 @@ -203,29 +214,39 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals vectors = [] for start, end in itertools.pairwise(region_boundaries): vector = np.ones(data.shape[1 - axis]) - vector[:start] = 0 - vector[end:] = 0 + vector[:start] = 0.0 + vector[end:] = 0.0 vectors.append(vector) return vectors - @staticmethod def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals - matrix: NDArray, orientation: Literal["horizontal", "vertical"], n_groups: int - ) -> NDArray: + self, + matrix: NDArray, + orientation: Literal["horizontal", "vertical"], + n_groups: int, + ) -> list[NDArray]: """Create a grouping vector to split vector into `n` groups.""" axis = 0 if orientation == "horizontal" else 1 # create a vector that is nan if the entire column/row is nan, 1 otherwise vector_is_nan = np.all(np.isnan(matrix), axis=axis) vector = np.ones(vector_is_nan.shape) - vector[vector_is_nan] = np.nan - # remove non-numeric (nan) elements at vector ends - # nan elements between numeric elements are kept - numeric_element_indices = np.argwhere(~np.isnan(vector)) - first_num_element = numeric_element_indices.min() - last_num_element = numeric_element_indices.max() + if (orientation == "horizontal" and self.ignore_nan_columns) or ( + orientation == "vertical" and self.ignore_nan_rows + ): + vector[vector_is_nan] = np.nan + + # remove non-numeric (nan) elements at vector ends + # nan elements between numeric elements are kept + numeric_element_indices = np.argwhere(~np.isnan(vector)) + first_num_element = numeric_element_indices.min() + last_num_element = numeric_element_indices.max() + else: + first_num_element = 0 + last_num_element = len(vector) - 1 + n_elements = last_num_element - first_num_element + 1 group_size = n_elements / n_groups From b33bc86005b072d6b4c63ed7d0d1dd913b85ea7a Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:27:35 +0200 Subject: [PATCH 40/55] Convert 2D ndarray to list of 1D arrays --- eitprocessing/roi_selection/gridselection.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 5431657e6..e8f9cb7bd 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -293,6 +293,10 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals final[ :, first_num_element : last_num_element + 1 ] = element_contribution_to_group + + # convert to list of vectors + final = [final[n, :] for n in range(final.shape[0])] + return final def matrix_layout(self) -> NDArray: From 4913ae50c971abd4018b52f09b240d9e5722da2e Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:36:24 +0200 Subject: [PATCH 41/55] Make split/no-split methods more alike --- eitprocessing/roi_selection/gridselection.py | 47 +++++++++++--------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index e8f9cb7bd..e712cb5f1 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -135,14 +135,18 @@ def find_grid(self, data) -> list[NDArray]: if self.split_rows else self._create_grouping_vector_no_split_pixels ) - horizontal_grouping_vectors = function(data, "horizontal", self.h_split) + horizontal_grouping_vectors = function( + data, horizontal=True, n_groups=self.h_split + ) function = ( self._create_grouping_vector_split_pixels if self.split_columns else self._create_grouping_vector_no_split_pixels ) - vertical_grouping_vectors = function(data, "vertical", self.v_split) + vertical_grouping_vectors = function( + data, horizontal=False, n_groups=self.v_split + ) matrices = [] for vertical, horizontal in itertools.product( @@ -159,13 +163,15 @@ def find_grid(self, data) -> list[NDArray]: def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals self, data: NDArray, - orientation: Literal["horizontal", "vertical"], - n_regions: int, + horizontal: bool, + n_groups: int, ) -> list[NDArray]: - axis = 0 if orientation == "horizontal" else 1 + """Create a grouping vector to split vector into `n` groups not allowing split elements.""" - if (orientation == "horizontal" and self.ignore_nan_columns) or ( - orientation == "vertical" and self.ignore_nan_rows + axis = 0 if horizontal else 1 + + if (horizontal and self.ignore_nan_columns) or ( + not horizontal and self.ignore_nan_rows ): is_numeric = ~np.isnan(data) numeric_vector_indices = np.argwhere(is_numeric.sum(axis) > 0) @@ -177,8 +183,8 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals n_vectors = last_vector_numeric - first_numeric_vector + 1 - if n_regions > n_vectors: - if orientation == "horizontal": # pylint: disable=no-else-raise + if n_groups > n_vectors: + if horizontal: # pylint: disable=no-else-raise raise InvalidHorizontalDivision( f"The number horizontal regions is larger than the " f"number of available columns ({n_vectors})." @@ -189,26 +195,26 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals f"number of available rows ({n_vectors})." ) - n_vectors_per_region = n_vectors / n_regions + n_vectors_per_region = n_vectors / n_groups if n_vectors_per_region % 1 > 0: - if orientation == "horizontal": + if horizontal: warnings.warn( f"The horizontal regions will not have an equal number of " - f"columns. {n_vectors} is not equally divisible by {n_regions}.", + f"columns. {n_vectors} is not equally divisible by {n_groups}.", UnevenHorizontalDivision, ) else: warnings.warn( f"The vertical regions will not have an equal number of " - f"columns. {n_vectors} is not equally divisible by {n_regions}.", + f"columns. {n_vectors} is not equally divisible by {n_groups}.", UnevenVerticalDivision, ) region_boundaries = [ first_numeric_vector + bisect.bisect_left(np.arange(n_vectors) / n_vectors_per_region, c) - for c in range(n_regions + 1) + for c in range(n_groups + 1) ] vectors = [] @@ -223,18 +229,19 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals self, matrix: NDArray, - orientation: Literal["horizontal", "vertical"], + horizontal: bool, n_groups: int, ) -> list[NDArray]: - """Create a grouping vector to split vector into `n` groups.""" - axis = 0 if orientation == "horizontal" else 1 + """Create a grouping vector to split vector into `n` groups allowing split elements.""" + + axis = 0 if horizontal else 1 # create a vector that is nan if the entire column/row is nan, 1 otherwise vector_is_nan = np.all(np.isnan(matrix), axis=axis) vector = np.ones(vector_is_nan.shape) - if (orientation == "horizontal" and self.ignore_nan_columns) or ( - orientation == "vertical" and self.ignore_nan_rows + if (horizontal and self.ignore_nan_columns) or ( + not horizontal and self.ignore_nan_rows ): vector[vector_is_nan] = np.nan @@ -252,7 +259,7 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals group_size = n_elements / n_groups if group_size < 1: - if orientation == "horizontal": # pylint: disable=no-else-raise + if horizontal: # pylint: disable=no-else-raise warnings.warn( f"The number horizontal regions ({n_groups}) is larger than the " f"number of available columns ({n_elements}).", From 170ceeacdf912ece914329de15981e0eae0d21fa Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Tue, 10 Oct 2023 20:36:30 +0200 Subject: [PATCH 42/55] Simplify matrix creation --- eitprocessing/roi_selection/gridselection.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index e712cb5f1..67ccc7326 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -152,10 +152,9 @@ def find_grid(self, data) -> list[NDArray]: for vertical, horizontal in itertools.product( vertical_grouping_vectors, horizontal_grouping_vectors ): - matrix = np.ones(data.shape) + # [None, :] converts to row vector, [:, None] converts to column vector + matrix = vertical[:, None] @ horizontal[None, :] matrix[np.isnan(data)] = np.nan - matrix *= horizontal - matrix *= vertical[:, None] # [:, None] converts to a column vector matrices.append(matrix) return matrices From ba0137ff86987998686902477d7d56a1940bc182 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 13:40:03 +0200 Subject: [PATCH 43/55] Fix swapped split_rows / split_columns --- eitprocessing/roi_selection/gridselection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 67ccc7326..02caf2c74 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -132,7 +132,7 @@ def __post_init__(self): def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels - if self.split_rows + if self.split_columns else self._create_grouping_vector_no_split_pixels ) horizontal_grouping_vectors = function( @@ -141,7 +141,7 @@ def find_grid(self, data) -> list[NDArray]: function = ( self._create_grouping_vector_split_pixels - if self.split_columns + if self.split_rows else self._create_grouping_vector_no_split_pixels ) vertical_grouping_vectors = function( From 7aaefce9a6a9ed5ca85d5133fcc6c65e72702c52 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 13:40:29 +0200 Subject: [PATCH 44/55] Reduce code repetition --- eitprocessing/roi_selection/gridselection.py | 36 ++++++++------------ 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 02caf2c74..5b72a92b4 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -98,18 +98,17 @@ class GridSelection(ROISelection): ignore_nan_rows: bool = True ignore_nan_columns: bool = True + def _check_attribute_type(self, name, type_): + """Checks whether an attribute is an instance of the given type.""" + attr = getattr(self, name) + if not isinstance(attr, type_): + message = f"Invalid type for `{name}`." + message += f"Should be {type_}, not {type(attr)}." + raise TypeError(message) + def __post_init__(self): - if not isinstance(self.v_split, int): - raise TypeError( - "Invalid type for `h_split`. " - f"Should be `int`, not {type(self.h_split)}." - ) - - if not isinstance(self.h_split, int): - raise TypeError( - "Invalid type for `h_split`. " - f"Should be `int`, not {type(self.h_split)}." - ) + self._check_attribute_type("v_split", int) + self._check_attribute_type("h_split", int) if self.v_split < 1: raise InvalidVerticalDivision("`v_split` can't be smaller than 1.") @@ -117,17 +116,10 @@ def __post_init__(self): if self.h_split < 1: raise InvalidHorizontalDivision("`h_split` can't be smaller than 1.") - if not isinstance(self.split_columns, bool): - raise TypeError( - "Invalid type for `split_columns`. " - f"Should be bool, not {type(self.h_split)}." - ) - - if not isinstance(self.split_rows, bool): - raise TypeError( - "Invalid type for `split_rows`. " - f"Should be bool, not {type(self.h_split)}." - ) + self._check_attribute_type("split_columns", bool) + self._check_attribute_type("split_rows", bool) + self._check_attribute_type("ignore_nan_columns", bool) + self._check_attribute_type("ignore_nan_rows", bool) def find_grid(self, data) -> list[NDArray]: function = ( From 337a9e370dcfc9f3d41ad56c5837905764a3a068 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 13:45:12 +0200 Subject: [PATCH 45/55] Add tests for initialization with split_rows/columns and ignore_nan_rows/columns --- tests/test_gridselection.py | 67 +++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 14549f047..a72f514d6 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -56,30 +56,69 @@ def matrices_from_string(string: str) -> list[np.ndarray]: @pytest.mark.parametrize( - "v_split,h_split,split_pixels,exception_type", + "v_split,h_split,split_columns,split_rows,ign_nan_cols,ign_nan_rows,exception_type", [ - (1, 1, False, None), - (0, 1, False, InvalidVerticalDivision), - (-1, 1, False, InvalidVerticalDivision), - (1.1, 1, False, TypeError), - (1, 0, False, InvalidHorizontalDivision), - (1, -1, False, InvalidHorizontalDivision), - (1, 1.1, False, TypeError), - (2, 2, "not a boolean", TypeError), - (2, 2, 1, TypeError), - (2, 2, 0, TypeError), + (1, 1, False, False, True, True, None), + (1, 1, False, True, True, True, None), + (1, 1, True, False, True, True, None), + (1, 1, True, True, False, True, None), + (1, 1, True, True, True, False, None), + # Vertical divider invalid + (0, 1, False, False, True, True, InvalidVerticalDivision), + (-1, 1, False, False, True, True, InvalidVerticalDivision), + (1.1, 1, False, False, True, True, TypeError), + # Horizontal divider invalid + (1, 0, False, False, True, True, InvalidHorizontalDivision), + (1, -1, False, False, True, True, InvalidHorizontalDivision), + (1, 1.1, False, False, True, True, TypeError), + # split_rows invalid + (2, 2, "not a boolean", False, True, True, TypeError), + (2, 2, 1, False, True, True, TypeError), + (2, 2, 0, False, True, True, TypeError), + # split_columns invalid + (2, 2, False, "not a boolean", True, True, TypeError), + (2, 2, False, 1, True, True, TypeError), + (2, 2, False, 0, True, True, TypeError), + # ignore_nan_rows invalid + (1, 1, False, False, "not a boolean", True, TypeError), + (1, 1, False, False, 1, True, TypeError), + # ignore_nan_columns invalid + (1, 1, False, False, True, "not a boolean", TypeError), + (1, 1, False, False, True, 1, TypeError), ], ) -def test_initialisation(v_split, h_split, split_pixels, exception_type): +def test_initialisation( + v_split, + h_split, + split_columns, + split_rows, + ign_nan_cols, + ign_nan_rows, + exception_type, +): + """Tests the initialisation of GridSelection and corresponding expected + errors. + + """ if exception_type is None: GridSelection( - v_split, h_split, split_columns=split_pixels, split_rows=split_pixels + v_split, + h_split, + split_columns=split_columns, + split_rows=split_rows, + ignore_nan_columns=ign_nan_cols, + ignore_nan_rows=ign_nan_rows, ) else: with pytest.raises(exception_type): GridSelection( - v_split, h_split, split_columns=split_pixels, split_rows=split_pixels + v_split, + h_split, + split_columns=split_columns, + split_rows=split_rows, + ignore_nan_columns=ign_nan_cols, + ignore_nan_rows=ign_nan_rows, ) From 0681d55d6e4f6a2b1460f25db48c8ff06d4b386f Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 13:46:08 +0200 Subject: [PATCH 46/55] Add tests for exceptions with(out) split_rows/columns --- tests/test_gridselection.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index a72f514d6..3890dcefc 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -274,18 +274,23 @@ def test_warnings(data_string, split_vh, split_rows, split_columns, warning_type @pytest.mark.parametrize( - "data_string,split_vh,exception_type", + "data_string,split_vh,split_rows,split_columns,exception_type", [ - ("RR,RR", (2, 2), None), - ("RR,RR", (3, 1), InvalidVerticalDivision), - ("RR,RR", (1, 3), InvalidHorizontalDivision), - ("RR,RR", (3, 1), InvalidDivision), - ("RR,RR", (1, 3), InvalidDivision), + ("RR,RR", (2, 2), False, False, None), + ("RR,RR", (3, 1), False, False, InvalidVerticalDivision), + ("RR,RR", (1, 3), False, False, InvalidHorizontalDivision), + ("RR,RR", (3, 1), False, False, InvalidDivision), + ("RR,RR", (1, 3), False, False, InvalidDivision), + ("RR,RR", (3, 2), True, False, None), + ("RR,RR", (3, 2), False, False, InvalidVerticalDivision), + ("RR,RR", (2, 3), False, True, None), + ("RR,RR", (2, 3), False, False, InvalidHorizontalDivision), ], ) -def test_exceptions(data_string, split_vh, exception_type): +def test_exceptions(data_string, split_vh, split_rows, split_columns, exception_type): + """Tests for exceptions raised when `find_grid()` is called.""" data = matrices_from_string(data_string)[0] - gs = GridSelection(*split_vh) + gs = GridSelection(*split_vh, split_columns=split_columns, split_rows=split_rows) if exception_type is None: gs.find_grid(data) From a191c6c8958201434ac0e800d45e1e4582709868 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 13:52:01 +0200 Subject: [PATCH 47/55] Improve documentation --- eitprocessing/roi_selection/gridselection.py | 25 ++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 5b72a92b4..a7690921a 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -121,7 +121,21 @@ def __post_init__(self): self._check_attribute_type("ignore_nan_columns", bool) self._check_attribute_type("ignore_nan_rows", bool) - def find_grid(self, data) -> list[NDArray]: + def find_grid(self, data: NDArray) -> list[NDArray]: + """ + Create 2D arrays to split a grid into regions. + + Create 2D arrays to split the given data into regions. The number of 2D + arrays will equal the number regions, which is the multiplicaiton of + `v_split` and `h_split`. + + Args: + data (NDArray): a 2D array containing any numeric or np.nan data. + + Returns: + list[NDArray]: a list of `n` 2D arrays where `n` is `v_split * + h_split`. + """ function = ( self._create_grouping_vector_split_pixels if self.split_columns @@ -157,7 +171,8 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals horizontal: bool, n_groups: int, ) -> list[NDArray]: - """Create a grouping vector to split vector into `n` groups not allowing split elements.""" + """Create a grouping vector to split vector into `n` groups not + allowing split elements.""" axis = 0 if horizontal else 1 @@ -223,7 +238,8 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals horizontal: bool, n_groups: int, ) -> list[NDArray]: - """Create a grouping vector to split vector into `n` groups allowing split elements.""" + """Create a grouping vector to split vector into `n` groups allowing + split elements.""" axis = 0 if horizontal else 1 @@ -298,7 +314,8 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals return final def matrix_layout(self) -> NDArray: - """Returns an array showing the layout of the matrices returned by `find_grid`.""" + """Returns a 2D array showing the layout of the matrices returned by + `find_grid`.""" n_regions = self.v_split * self.h_split return np.reshape(np.arange(n_regions), (self.v_split, self.h_split)) From 625d4815bc0d717955c201f37f21a8a89622d32f Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Wed, 11 Oct 2023 14:02:58 +0200 Subject: [PATCH 48/55] Linting --- eitprocessing/roi_selection/gridselection.py | 3 +-- tests/test_gridselection.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index a7690921a..7e05d9547 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -1,7 +1,6 @@ import bisect import itertools import warnings -from dataclasses import InitVar from dataclasses import dataclass from dataclasses import field from typing import Literal @@ -266,7 +265,7 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals group_size = n_elements / n_groups if group_size < 1: - if horizontal: # pylint: disable=no-else-raise + if horizontal: warnings.warn( f"The number horizontal regions ({n_groups}) is larger than the " f"number of available columns ({n_elements}).", diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 3890dcefc..0bb3ad7bb 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -87,7 +87,7 @@ def matrices_from_string(string: str) -> list[np.ndarray]: (1, 1, False, False, True, 1, TypeError), ], ) -def test_initialisation( +def test_initialisation( # pylint: disable=too-many-arguments v_split, h_split, split_columns, From 737d111349acd1c355ff2e562b1b79ce8ea4e1b0 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:26:55 +0200 Subject: [PATCH 49/55] Add test for split pixels and NaN values, improve test documentation. --- tests/test_gridselection.py | 459 ++++++++++++++++++++++++------------ 1 file changed, 310 insertions(+), 149 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 0bb3ad7bb..3e2aa46e0 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -1,4 +1,5 @@ import warnings +from typing import Final import numpy as np import pytest from eitprocessing.roi_selection.gridselection import GridSelection @@ -11,11 +12,17 @@ from eitprocessing.roi_selection.gridselection import UnevenVerticalDivision -def matrices_from_string(string: str) -> list[np.ndarray]: - """Generates a list of matrices from a string containing a representation of matrices. +N: Final = np.nan # shorthand for readabililty + + +def matrix_from_string(string: str) -> np.ndarray: + """ + Generate a of matrix from a string containing a representation of that + matrix. - A represtation of matrices contains one character per cell that describes the value of that cell. - Rows are delimited by commas. Matrices are delimited by semi-colons. + A representation of a matrix contains one character per cell that describes + the value of that cell. Rows are delimited by commas. Matrices are + delimited by semi-colons. The following characters are transformed to these corresponding values: - T / 1 -> 1 @@ -24,86 +31,102 @@ def matrices_from_string(string: str) -> list[np.ndarray]: - N / any other character -> np.nan Examples: - >>> matrices_from_string("1T1,FNR") - [array([[ 1., 1., 1.], - [ 0., nan, 40.]])] - >>> matrices_from_string("1T1,FNR") - [array([[ 1., 1., 1.], - [ 0., nan, 37]])] - >>> matrices_from_string("RR,RR;RRR;1R") - [array([[21., 80.], - [43., 10.]]), - array([[26., 43., 62.]]), - array([[ 1., 89.]])] + >>> matrix_from_string("1T1,FNR") + array([[ 1., 1., 1.], + [ 0., nan, 93.]]) + >>> matrix_from_string("RRR,RNR") + array([[10., 68., 20.], + [46., nan, 25.]]) """ - matrices = [] - for part in string.split(";"): - str_matrix = np.array([tuple(row) for row in part.split(",")], dtype="object") - matrix = np.full(str_matrix.shape, np.nan, dtype=np.floating) - matrix[np.nonzero(str_matrix == "1")] = 1 - matrix[np.nonzero(str_matrix == "T")] = 1 - matrix[np.nonzero(str_matrix == "0")] = 0 - matrix[np.nonzero(str_matrix == "F")] = 0 - matrix = np.where( - str_matrix == "R", - np.random.default_rng().integers(2, 100, matrix.shape), - matrix, - ) - matrices.append(matrix) + str_matrix = np.array([tuple(row) for row in string.split(",")], dtype="object") + matrix = np.full(str_matrix.shape, np.nan, dtype=np.floating) + matrix[np.nonzero(str_matrix == "1")] = 1 + matrix[np.nonzero(str_matrix == "T")] = 1 + matrix[np.nonzero(str_matrix == "0")] = 0 + matrix[np.nonzero(str_matrix == "F")] = 0 + matrix = np.where( + str_matrix == "R", + np.random.default_rng().integers(2, 100, matrix.shape), + matrix, + ) + return matrix + + +def matrices_from_string(string: str) -> list[np.ndarray]: + """ + Generate a list of matrices from a string representation containing + multiple matrices. - return matrices + The input string should contain the representation of one or multiple + matrices to be used in `matrix_from_string()`, deliminated by `;`. + + Examples: + >>>matrices_from_string("RR,RR;RRR;1R") + [array([[64., 82.], + [40., 65.]]), + array([[56., 40., 88.]]), + array([[ 1., 76.]])] + """ + + return [matrix_from_string(part) for part in string.split(";")] @pytest.mark.parametrize( - "v_split,h_split,split_columns,split_rows,ign_nan_cols,ign_nan_rows,exception_type", + "split_vh,split_columns,split_rows,ign_nan_cols,ign_nan_rows,exception_type", [ - (1, 1, False, False, True, True, None), - (1, 1, False, True, True, True, None), - (1, 1, True, False, True, True, None), - (1, 1, True, True, False, True, None), - (1, 1, True, True, True, False, None), + ((1, 1), False, False, True, True, None), + ((1, 1), False, True, True, True, None), + ((1, 1), True, False, True, True, None), + ((1, 1), True, True, False, True, None), + ((1, 1), True, True, True, False, None), # Vertical divider invalid - (0, 1, False, False, True, True, InvalidVerticalDivision), - (-1, 1, False, False, True, True, InvalidVerticalDivision), - (1.1, 1, False, False, True, True, TypeError), - # Horizontal divider invalid - (1, 0, False, False, True, True, InvalidHorizontalDivision), - (1, -1, False, False, True, True, InvalidHorizontalDivision), - (1, 1.1, False, False, True, True, TypeError), + ((0, 1), False, False, True, True, InvalidVerticalDivision), + ((-1, 1), False, False, True, True, InvalidVerticalDivision), + ((1.1, 1), False, False, True, True, TypeError), + # ( Horiz)ontal divider invalid + ((1, 0), False, False, True, True, InvalidHorizontalDivision), + ((1, -1), False, False, True, True, InvalidHorizontalDivision), + ((1, 1.1), False, False, True, True, TypeError), # split_rows invalid - (2, 2, "not a boolean", False, True, True, TypeError), - (2, 2, 1, False, True, True, TypeError), - (2, 2, 0, False, True, True, TypeError), + ((2, 2), "not a boolean", False, True, True, TypeError), + ((2, 2), 1, False, True, True, TypeError), + ((2, 2), 0, False, True, True, TypeError), # split_columns invalid - (2, 2, False, "not a boolean", True, True, TypeError), - (2, 2, False, 1, True, True, TypeError), - (2, 2, False, 0, True, True, TypeError), + ((2, 2), False, "not a boolean", True, True, TypeError), + ((2, 2), False, 1, True, True, TypeError), + ((2, 2), False, 0, True, True, TypeError), # ignore_nan_rows invalid - (1, 1, False, False, "not a boolean", True, TypeError), - (1, 1, False, False, 1, True, TypeError), + ((1, 1), False, False, "not a boolean", True, TypeError), + ((1, 1), False, False, 1, True, TypeError), # ignore_nan_columns invalid - (1, 1, False, False, True, "not a boolean", TypeError), - (1, 1, False, False, True, 1, TypeError), + ((1, 1), False, False, True, "not a boolean", TypeError), + ((1, 1), False, False, True, 1, TypeError), ], ) def test_initialisation( # pylint: disable=too-many-arguments - v_split, - h_split, - split_columns, - split_rows, - ign_nan_cols, - ign_nan_rows, - exception_type, + split_vh: tuple[int, int], + split_columns: bool, + split_rows: bool, + ign_nan_cols: bool, + ign_nan_rows: bool, + exception_type: type[Exception] | None, ): - """Tests the initialisation of GridSelection and corresponding expected - errors. - """ + Test the initialisation of GridSelection and corresponding expected errors. + + Args: + split_columns (bool): whether to allow splitting columns. + split_rows (bool): whether to allow splitting rows. + ign_nan_cols (bool): whether to ignore NaN columns. + ign_nan_rows (bool): whether to ignonre NaN rows. + exception_type (type[Exception] | None): type of exception expected to + be raised. + """ + if exception_type is None: GridSelection( - v_split, - h_split, + *split_vh, split_columns=split_columns, split_rows=split_rows, ignore_nan_columns=ign_nan_cols, @@ -113,8 +136,7 @@ def test_initialisation( # pylint: disable=too-many-arguments else: with pytest.raises(exception_type): GridSelection( - v_split, - h_split, + *split_vh, split_columns=split_columns, split_rows=split_rows, ignore_nan_columns=ign_nan_cols, @@ -122,6 +144,96 @@ def test_initialisation( # pylint: disable=too-many-arguments ) +@pytest.mark.parametrize( + "data_string,split_vh,split_rows,split_columns,warning_type", + [ + ("RR,RR", (2, 2), False, False, None), + ("RRR,RRR", (2, 2), False, False, UnevenHorizontalDivision), + ("RRR,RRR", (1, 3), False, False, None), + ("RRRR,RRRR", (1, 3), False, False, UnevenHorizontalDivision), + ("RR,RR,RR", (2, 1), False, False, UnevenVerticalDivision), + ("RR,RR,RR", (3, 1), False, False, None), + ("NN,RR,RR", (2, 1), False, False, None), + ("R", (2, 1), True, True, MoreVerticalGroupsThanRows), + ("R", (1, 2), True, True, MoreHorizontalGroupsThanColumns), + ("RRR,RRR,RRR", (4, 3), True, True, MoreVerticalGroupsThanRows), + ], +) +def test_warnings( + data_string: str, + split_vh: tuple[int, int], + split_rows: bool, + split_columns: bool, + warning_type: type[Warning] | None, +): + """ + Test for warnings generated when `find_grid()` is called. + + Args: + data_string (str): represents the input data, to be converted using + `matrices_from_string()` + split_vh (tuple[int, int]): `v_split` and `h_split`. + split_rows (bool): whether to allow splitting rows. + split_columns (bool): whether to allow splitting columns. + warning_type (type[Warning] | None): type of warning to be expected. + """ + + data = matrix_from_string(data_string) + gs = GridSelection(*split_vh, split_rows=split_rows, split_columns=split_columns) + + if warning_type is None: + # catch all warnings and raises them + with warnings.catch_warnings(): + warnings.simplefilter("error") + gs.find_grid(data) + else: + with pytest.warns(warning_type): + gs.find_grid(data) + + +@pytest.mark.parametrize( + "data_string,split_vh,split_rows,split_columns,exception_type", + [ + ("RR,RR", (2, 2), False, False, None), + ("RR,RR", (3, 1), False, False, InvalidVerticalDivision), + ("RR,RR", (1, 3), False, False, InvalidHorizontalDivision), + ("RR,RR", (3, 1), False, False, InvalidDivision), + ("RR,RR", (1, 3), False, False, InvalidDivision), + ("RR,RR", (3, 2), True, False, None), + ("RR,RR", (3, 2), False, False, InvalidVerticalDivision), + ("RR,RR", (2, 3), False, True, None), + ("RR,RR", (2, 3), False, False, InvalidHorizontalDivision), + ], +) +def test_exceptions( + data_string: str, + split_vh: tuple[int, int], + split_rows: bool, + split_columns: bool, + exception_type: type[Exception] | None, +): + """ + Test for exceptions raised when `find_grid()` is called. + + Args: + data_string (str): represents the input data, to be converted using `matrices_from_string()`. + split_vh (tuple[int, int]): `v_split` and `h_split`. + split_rows (bool): whether to allow splitting rows. + split_columns (bool): whether to allow splitting columns. + exception_type (type[Exception] | None): type of exception expected to be raised. + """ + + data = matrix_from_string(data_string) + gs = GridSelection(*split_vh, split_columns=split_columns, split_rows=split_rows) + + if exception_type is None: + gs.find_grid(data) + + else: + with pytest.raises(exception_type): + gs.find_grid(data) + + @pytest.mark.parametrize( "shape,split_vh,result_string", [ @@ -150,7 +262,19 @@ def test_initialisation( # pylint: disable=too-many-arguments ((2, 2), (1, 1), "TT,TT"), ], ) -def test_no_split_pixels_no_nans(shape, split_vh, result_string): +def test_no_split_pixels_no_nans( + shape: tuple[int, int], split_vh: tuple[int, int], result_string: str +): + """ + Test `find_grid()` without split rows/columns and no NaN values. + + Args: + shape (tuple[int, int]): shape of the input data to be generated. + split_vh (tuple[int, int]): `v_split` and `h_split`. + result_string (str): represents the expected result, to be converted + using `matrices_from_string()`. + """ + data = np.random.default_rng().integers(1, 100, shape) result_matrices = matrices_from_string(result_string) @@ -164,6 +288,57 @@ def test_no_split_pixels_no_nans(shape, split_vh, result_string): assert np.array_equal(matrices, result_matrices) +@pytest.mark.parametrize( + "data_string,split_vh,result_string", + [ + ("NNN,NRR,NRR", (1, 1), "NNN,NTT,NTT"), + ( + "NNN,RRR,RRR,RRR,RRR", + (2, 2), + "NNN,TTF,TTF,FFF,FFF;" + "NNN,FFT,FFT,FFF,FFF;" + "NNN,FFF,FFF,TTF,TTF;" + "NNN,FFF,FFF,FFT,FFT", + ), + ( + "NNNNNN,NNNNNN,NRRRRR,RNRRRR,NNNNNN", + (2, 2), + "NNNNNN,NNNNNN,NTTFFF,FNFFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFTTT,FNFFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFFFF,TNTFFF,NNNNNN;" + "NNNNNN,NNNNNN,NFFFFF,FNFTTT,NNNNNN", + ), + ], +) +def test_no_split_pixels_nans( + data_string: str, split_vh: tuple[int, int], result_string: str +): + """ + Test `find_grid()` without row/column splitting, with NaN values. + + Args: + data_string (str): represents the input data, to be converted using `matrices_from_string()`. + split_vh (tuple[int, int]): `v_split` and `h_split`. + result_string (str): represents the expected result, to be converted + using `matrices_from_string()`. + """ + + data = matrix_from_string(data_string) + numeric_values = np.ones(data.shape) + numeric_values[np.isnan(data)] = np.nan + result = matrices_from_string(result_string) + + v_split, h_split = split_vh + gs = GridSelection(v_split, h_split, split_rows=False, split_columns=False) + + matrices = gs.find_grid(data) + num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) + + assert len(matrices) == h_split * v_split + assert np.array_equal(num_appearances, numeric_values, equal_nan=True) + assert np.array_equal(matrices, result, equal_nan=True) + + @pytest.mark.parametrize( "shape,split_vh,result", [ @@ -186,9 +361,30 @@ def test_no_split_pixels_no_nans(shape, split_vh, result_string): [[0.0, 0.0, 1 / 3, 1.0]], ], ), + ( + (3, 3), + (2, 2), + [ + [[1, 0.5, 0], [0.5, 0.25, 0], [0, 0, 0]], + [[0, 0.5, 1], [0, 0.25, 0.5], [0, 0, 0]], + [[0, 0, 0], [0.5, 0.25, 0], [1, 0.5, 0]], + [[0, 0, 0], [0, 0.25, 0.5], [0, 0.5, 1]], + ], + ), ], ) -def test_split_pixels_no_nans(shape, split_vh, result): +def test_split_pixels_no_nans( + shape: tuple[int, int], split_vh: tuple[int, int], result: list[list[list[float]]] +): + """ + Test `find_grid()` with split rows/columns and no NaN values. + + Args: + shape (tuple[int, int]): shape of the input data to be generated. + split_vh (tuple[int, int]): `v_split` and `h_split`. + result (str): list of lists to be converted to matrices, representing + the expected result. + """ data = np.random.default_rng().integers(1, 100, shape) expected_result = [np.array(r) for r in result] @@ -206,98 +402,54 @@ def test_split_pixels_no_nans(shape, split_vh, result): @pytest.mark.parametrize( - "data_string,split_vh,result_string", - [ - ("NNN,NRR,NRR", (1, 1), "NNN,NTT,NTT"), + "data_string,split_vh,result", + ( ( - "NNN,RRR,RRR,RRR,RRR", + "NRRR,NRRR", (2, 2), - "NNN,TTF,TTF,FFF,FFF;" - "NNN,FFT,FFT,FFF,FFF;" - "NNN,FFF,FFF,TTF,TTF;" - "NNN,FFF,FFF,FFT,FFT", + [ + [[N, 1, 0.5, 0], [N, 0, 0, 0]], + [[N, 0, 0.5, 1], [N, 0, 0, 0]], + [[N, 0, 0, 0], [N, 1, 0.5, 0]], + [[N, 0, 0, 0], [N, 0, 0.5, 1]], + ], ), ( - "NNNNNN,NNNNNN,NRRRRR,RNRRRR,NNNNNN", + "RNRR,RNRR,RNRR,NNNN", (2, 2), - "NNNNNN,NNNNNN,NTTFFF,FNFFFF,NNNNNN;" - "NNNNNN,NNNNNN,NFFTTT,FNFFFF,NNNNNN;" - "NNNNNN,NNNNNN,NFFFFF,TNTFFF,NNNNNN;" - "NNNNNN,NNNNNN,NFFFFF,FNFTTT,NNNNNN", + [ + [[1, N, 0, 0], [0.5, N, 0, 0], [0, N, 0, 0], [N, N, N, N]], + [[0, N, 1, 1], [0, N, 0.5, 0.5], [0, N, 0, 0], [N, N, N, N]], + [[0, N, 0, 0], [0.5, N, 0, 0], [1, N, 0, 0], [N, N, N, N]], + [[0, N, 0, 0], [0, N, 0.5, 0.5], [0, N, 1, 1], [N, N, N, N]], + ], ), - ], + ), ) -def test_no_split_pixels_nans(data_string, split_vh, result_string): - data = matrices_from_string(data_string)[0] +def test_split_pixels_nans(data_string, split_vh, result): + """ + Test `find_grid()` with row/column splitting, with NaN values. + + Args: + data_string (str): represents the input data, to be converted using `matrices_from_string()`. + split_vh (tuple[int, int]): `v_split` and `h_split`. + result (str): list of list representation of matrices, representing + the expected result. + """ + + data = matrix_from_string(data_string) + expected_result = [np.array(r) for r in result] numeric_values = np.ones(data.shape) numeric_values[np.isnan(data)] = np.nan - result = matrices_from_string(result_string) - v_split, h_split = split_vh - gs = GridSelection(v_split, h_split, split_rows=False, split_columns=False) + gs = GridSelection(*split_vh, split_rows=True, split_columns=True) + actual_result = gs.find_grid(data) - matrices = gs.find_grid(data) - num_appearances = np.sum(np.stack(matrices, axis=-1), axis=-1) + num_appearances = np.sum(np.stack(actual_result, axis=-1), axis=-1) - assert len(matrices) == h_split * v_split + assert len(actual_result) == np.prod(split_vh) + assert len(actual_result) == len(expected_result) assert np.array_equal(num_appearances, numeric_values, equal_nan=True) - assert np.array_equal(matrices, result, equal_nan=True) - - -@pytest.mark.parametrize( - "data_string,split_vh,split_rows,split_columns,warning_type", - [ - ("RR,RR", (2, 2), False, False, None), - ("RRR,RRR", (2, 2), False, False, UnevenHorizontalDivision), - ("RRR,RRR", (1, 3), False, False, None), - ("RRRR,RRRR", (1, 3), False, False, UnevenHorizontalDivision), - ("RR,RR,RR", (2, 1), False, False, UnevenVerticalDivision), - ("RR,RR,RR", (3, 1), False, False, None), - ("NN,RR,RR", (2, 1), False, False, None), - ("R", (2, 1), True, True, MoreVerticalGroupsThanRows), - ("R", (1, 2), True, True, MoreHorizontalGroupsThanColumns), - ("RRR,RRR,RRR", (4, 3), True, True, MoreVerticalGroupsThanRows), - ], -) -def test_warnings(data_string, split_vh, split_rows, split_columns, warning_type): - data = matrices_from_string(data_string)[0] - gs = GridSelection(*split_vh, split_rows=split_rows, split_columns=split_columns) - - if warning_type is None: - # catch all warnings and raises them - with warnings.catch_warnings(): - warnings.simplefilter("error") - gs.find_grid(data) - else: - with pytest.warns(warning_type): - gs.find_grid(data) - - -@pytest.mark.parametrize( - "data_string,split_vh,split_rows,split_columns,exception_type", - [ - ("RR,RR", (2, 2), False, False, None), - ("RR,RR", (3, 1), False, False, InvalidVerticalDivision), - ("RR,RR", (1, 3), False, False, InvalidHorizontalDivision), - ("RR,RR", (3, 1), False, False, InvalidDivision), - ("RR,RR", (1, 3), False, False, InvalidDivision), - ("RR,RR", (3, 2), True, False, None), - ("RR,RR", (3, 2), False, False, InvalidVerticalDivision), - ("RR,RR", (2, 3), False, True, None), - ("RR,RR", (2, 3), False, False, InvalidHorizontalDivision), - ], -) -def test_exceptions(data_string, split_vh, split_rows, split_columns, exception_type): - """Tests for exceptions raised when `find_grid()` is called.""" - data = matrices_from_string(data_string)[0] - gs = GridSelection(*split_vh, split_columns=split_columns, split_rows=split_rows) - - if exception_type is None: - gs.find_grid(data) - - else: - with pytest.raises(exception_type): - gs.find_grid(data) @pytest.mark.parametrize( @@ -310,7 +462,16 @@ def test_exceptions(data_string, split_vh, split_rows, split_columns, exception_ ((3, 4), [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]), ], ) -def test_matrix_layout(split_vh, result): +def test_matrix_layout(split_vh: tuple[int, int], result: list[list[int]]): + """ + Test `matrix_layout()` method. + + Args: + split_vh (tuple[int, int]): `v_split` and `h_split`. + result (list[list[int]]): list representation of a matrix, representing + the expected result. + """ + gs = GridSelection(*split_vh) layout = gs.matrix_layout() From c0a66eb430c27e239961bd9d3bc86efdcc423a14 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:44:45 +0200 Subject: [PATCH 50/55] Improve type hints for numpy arrays. --- tests/test_gridselection.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_gridselection.py b/tests/test_gridselection.py index 3e2aa46e0..24f98bdb4 100644 --- a/tests/test_gridselection.py +++ b/tests/test_gridselection.py @@ -2,6 +2,7 @@ from typing import Final import numpy as np import pytest +from numpy.typing import NDArray from eitprocessing.roi_selection.gridselection import GridSelection from eitprocessing.roi_selection.gridselection import InvalidDivision from eitprocessing.roi_selection.gridselection import InvalidHorizontalDivision @@ -15,7 +16,7 @@ N: Final = np.nan # shorthand for readabililty -def matrix_from_string(string: str) -> np.ndarray: +def matrix_from_string(string: str) -> NDArray: """ Generate a of matrix from a string containing a representation of that matrix. @@ -53,7 +54,7 @@ def matrix_from_string(string: str) -> np.ndarray: return matrix -def matrices_from_string(string: str) -> list[np.ndarray]: +def matrices_from_string(string: str) -> list[NDArray]: """ Generate a list of matrices from a string representation containing multiple matrices. From 8369ce5dea845a7cea2db2bf3f5e07fc04554845 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:47:42 +0200 Subject: [PATCH 51/55] Use np.outer for row/column vector multiplication. np.outer(a, b) is the same as a[:, np.newaxis] @ b[np.newaxis, :], when a and b are 1D arrays. --- eitprocessing/roi_selection/gridselection.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 7e05d9547..585b0c044 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -157,8 +157,7 @@ def find_grid(self, data: NDArray) -> list[NDArray]: for vertical, horizontal in itertools.product( vertical_grouping_vectors, horizontal_grouping_vectors ): - # [None, :] converts to row vector, [:, None] converts to column vector - matrix = vertical[:, None] @ horizontal[None, :] + matrix = np.outer(vertical, horizontal) matrix[np.isnan(data)] = np.nan matrices.append(matrix) From b4d6c5cd36314b2da1131d4d788a2dbb570dded1 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:49:05 +0200 Subject: [PATCH 52/55] Use np.newaxis where appropriate. np.newaxis is an alias of None, but more clear when reading the code. --- eitprocessing/roi_selection/gridselection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 585b0c044..2812e66df 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -279,7 +279,7 @@ def _create_grouping_vector_split_pixels( # pylint: disable=too-many-locals # find the right boundaries (upper values) of each group right_boundaries = (np.arange(n_groups) + 1) * group_size - right_boundaries = right_boundaries[:, None] # converts it to a row vector + right_boundaries = right_boundaries[:, np.newaxis] # converts to row vector # each row in the base represents one group base = np.tile(np.arange(n_elements), (n_groups, 1)) From a712e691f75eac1a71634f3baf016fbe4d3ee3fc Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:50:06 +0200 Subject: [PATCH 53/55] Use dictionary for choosing method, keeping it DRY. --- eitprocessing/roi_selection/gridselection.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 2812e66df..058683d7d 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -135,21 +135,16 @@ def find_grid(self, data: NDArray) -> list[NDArray]: list[NDArray]: a list of `n` 2D arrays where `n` is `v_split * h_split`. """ - function = ( - self._create_grouping_vector_split_pixels - if self.split_columns - else self._create_grouping_vector_no_split_pixels - ) - horizontal_grouping_vectors = function( + grouping_method = { + True: self._create_grouping_vector_split_pixels, + False: self._create_grouping_vector_no_split_pixels, + } + + horizontal_grouping_vectors = grouping_method[self.split_columns]( data, horizontal=True, n_groups=self.h_split ) - function = ( - self._create_grouping_vector_split_pixels - if self.split_rows - else self._create_grouping_vector_no_split_pixels - ) - vertical_grouping_vectors = function( + vertical_grouping_vectors = grouping_method[self.split_rows]( data, horizontal=False, n_groups=self.v_split ) From ad1a09b5543762e6e7e17785418e4ab31455fb76 Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 11:50:16 +0200 Subject: [PATCH 54/55] Remove superfluous f-string indicators. --- eitprocessing/roi_selection/gridselection.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/eitprocessing/roi_selection/gridselection.py b/eitprocessing/roi_selection/gridselection.py index 058683d7d..f01e2a12b 100644 --- a/eitprocessing/roi_selection/gridselection.py +++ b/eitprocessing/roi_selection/gridselection.py @@ -185,12 +185,12 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals if n_groups > n_vectors: if horizontal: # pylint: disable=no-else-raise raise InvalidHorizontalDivision( - f"The number horizontal regions is larger than the " + "The number horizontal regions is larger than the " f"number of available columns ({n_vectors})." ) else: raise InvalidVerticalDivision( - f"The number vertical regions is larger than the " + "The number vertical regions is larger than the " f"number of available rows ({n_vectors})." ) @@ -199,13 +199,13 @@ def _create_grouping_vector_no_split_pixels( # pylint: disable=too-many-locals if n_vectors_per_region % 1 > 0: if horizontal: warnings.warn( - f"The horizontal regions will not have an equal number of " + "The horizontal regions will not have an equal number of " f"columns. {n_vectors} is not equally divisible by {n_groups}.", UnevenHorizontalDivision, ) else: warnings.warn( - f"The vertical regions will not have an equal number of " + "The vertical regions will not have an equal number of " f"columns. {n_vectors} is not equally divisible by {n_groups}.", UnevenVerticalDivision, ) From af172d7cceadfc544f0343ac372cef126adb486e Mon Sep 17 00:00:00 2001 From: Peter Somhorst Date: Fri, 27 Oct 2023 13:50:08 +0200 Subject: [PATCH 55/55] Remove unfinished FunctionalLungSpace selection class This class has been moved to its own branch 115_functionallungspace_psomhorst --- .../roi_selection/functionallungspace.py | 28 ------------------- 1 file changed, 28 deletions(-) delete mode 100644 eitprocessing/roi_selection/functionallungspace.py diff --git a/eitprocessing/roi_selection/functionallungspace.py b/eitprocessing/roi_selection/functionallungspace.py deleted file mode 100644 index 442549da5..000000000 --- a/eitprocessing/roi_selection/functionallungspace.py +++ /dev/null @@ -1,28 +0,0 @@ -import numpy as np -from . import ROISelection - - -class FunctionalLungSpace(ROISelection): - def __init__( - self, - threshold: float, - min_output_size: int = 0, - min_cluster_size: int = 0, - ): - self.threshold = threshold - self.min_output_size = min_output_size - self.min_cluster_size = min_cluster_size - - def find_ROI( - self, - data: np.ndarray, - ): - max_pixel = np.nanmax(data, axis=-1) - min_pixel = np.nanmin(data, axis=-1) - pixel_amplitude = max_pixel - min_pixel - max_pixel_amplitude = np.max(pixel_amplitude) - - output = pixel_amplitude > (max_pixel_amplitude * self.threshold) - output = self.minimal_cluster(output) - - return output