diff --git a/.bumpversion.toml b/.bumpversion.toml index 8dd82c5..df7edfc 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "v0.2.0" +current_version = "v0.3.0" commit = true commit_args = "--no-verify" tag = true diff --git a/README.md b/README.md index 88f66d2..5f5a072 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Build Status](https://github.com/NanoComp/imageruler/workflows/CI/badge.svg)](https://github.com/NanoComp/imageruler/actions) -`v0.2.0` +`v0.3.0` Imageruler is a free Python program to compute the minimum length scale of binary images which are typically designs produced by topology optimization. The algorithm is described in Section 2 of [J. Optical Society of America B, Vol. 42, pp. A161-A176 (2024)](https://opg.optica.org/josab/abstract.cfm?uri=josab-41-2-A161) and is based on morphological transformations implemented using [OpenCV](https://github.com/opencv/opencv). Imageruler also supports 1d binary images. @@ -7,16 +7,23 @@ Imageruler is a free Python program to compute the minimum length scale of binar The procedure used by Imageruler for determining the minimum length scale of the solid regions in a binary image involves four steps: -1. Binarize the 2d array $\rho$ representing the image such that each of its elements is a Boolean value for solid (true) and void (false). -2. For a circular-ball [kernel](https://en.wikipedia.org/wiki/Kernel_(image_processing)) with diameter $d$, compute the morphological opening $\mathcal{O}_d(\rho)$ and obtain its difference with the original array via $\mathcal{O}_d(\rho) \oplus \rho$, where $\oplus$ denotes the exclusive-or operator. -3. Check whether $\mathcal{O}_d(\rho) \oplus \rho$ contains a solid pixel within the interior solid regions of $\rho$. If no, $d$ is less than the minimum length scale of solid regions. If yes, $d$ is equal or greater than the minimum length scale of the solid regions. The interior of the solid regions of $\rho$ is obtained by morphological erosion using a "cross" kernel of size $3\times3$ pixels. -4. Use a [binary search](https://en.wikipedia.org/wiki/Binary_search_algorithm) and repeat Steps 2 and 3 to find the smallest $d$ for which the check in Step 3 evaluates to true. This value of $d$ is considered to be the minimum length scale of the solid regions. +1. Binarize the 1d or 2d array $\rho$ representing the image such that each of its elements is a Boolean value for solid (true) and void (false). +2. For a circular-ball [kernel](https://en.wikipedia.org/wiki/Kernel_(image_processing)) with diameter $d$, compute the morphological opening $\mathcal{O}_d(\rho)$ and obtain its difference with the original array via $\mathcal{O}_d(\rho) \oplus \rho$, where $\oplus$ denotes the exclusive-or operator. In the strictest sense, solid pixels in $\mathcal{O}_d(\rho) \oplus \rho$ are violations of the length scale $d$. +3. Identify pixels where violations are to be ignored. The scheme used to identify ignored violating pixels is specified by the `IgnoreScheme`; by default, these include pixels at the edges of large features. Remove any violations to be ignored from consideration. +4. Check whether there are any remaining violations in $\mathcal{O}_d(\rho) \oplus \rho$. If there are no violations pixels, $d$ is less than or equal to the minimum length scale of solid regions. +5. Use a [binary search](https://en.wikipedia.org/wiki/Binary_search_algorithm) and repeat Steps 2 and 3 to find the largest $d$ for which no violating pixels exist. The search has some allowance for non-monotonicity, i.e. situations where there are violations for $d$ but not $d + 1$. This largest value of $d$ for which there are no violations is considered to be the minimum length scale of the solid regions. -To estimate the minimum length scale of the void regions, the binary image is inverted after the binarization of Step 1: $\rho \rightarrow \neg \rho$ such that the solid and void regions are interchanged. The remaining Steps 2-4 are unchanged. This approach is equivalent to computing $\mathcal{C}_d(\rho) \oplus \rho$ in Step 2 and then checking its overlap with the interior pixels of the void regions of $\rho$ in Step 3. $\mathcal{C}_d(\rho)$ denotes morphological closing. +To estimate the minimum length scale of the void regions, the binary image is inverted after the binarization of Step 1: $\rho \rightarrow \neg \rho$ such that the solid and void regions are interchanged. The remaining Steps 2-5 are unchanged. This approach is equivalent to computing $\mathcal{C}_d(\rho) \oplus \rho$ in Step 2 and then checking its overlap with the interior pixels of the void regions of $\rho$ in Step 3. $\mathcal{C}_d(\rho)$ denotes morphological closing. -The minimum length scale of $\rho$ is the smaller of the minimum length scales of the solid and void regions. Rather than determining these separately, it is possible to compute their minimum simultaneously using $\mathcal{O}_d(\rho) \oplus \mathcal{C}_d(\rho)$ in Step 2 and then to check its overlap with the union of the interior pixels of the solid and void regions of $\rho$ in Step 3. This approach involves a single binary search rather than two. +The minimum length scale of $\rho$ is the smaller of the minimum length scales of the solid and void regions. Rather than determining these separately, it is possible in principle to compute their minimum simultaneously using $\mathcal{O}_d(\rho) \oplus \mathcal{C}_d(\rho)$ and then to check its overlap with the union of the interior pixels of the solid and void regions of $\rho$. This approach involves a single binary search rather than two. -For a 1d binary image, the algorithm simply finds the minimum length among all solid or void segments. +## Schemes for Identifying Ignored Violations +The `ignore_scheme` is an optional argument to top-level functions such as `imageruler.minimum_length_scale`. The choice may affect the length scale value reported for a given design; the possible values are as follows: + +- `IgnoreScheme.NONE`: a strict scheme in which no violations are ignored. +- `IgnoreScheme.EDGES`: ignores violations for any solid pixel removed by erosion. +- `IgnoreScheme.LARGE_FEATURE_EDGES`: ignores violations at the edges of large features only. A pixel is on the edge of a large feature if it removed by erosion and adjacent to an interior pixel. Interior pixels are those which are solid and surrounded on all sides (in an 8-connected sense) by solid pixels. +- `IgnoreScheme.LARGE_FEATURE_EDGES_STRICT`: the default choice. Similar to `LARGE_FEATURE_EDGES`, but uses a more strict algorithm to detect edges and does not ignore checkerboard patterns. ## Note on Accuracy diff --git a/docs/notebooks/advanced.ipynb b/docs/notebooks/advanced.ipynb index c591294..9a8a9c5 100644 --- a/docs/notebooks/advanced.ipynb +++ b/docs/notebooks/advanced.ipynb @@ -116,7 +116,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Here, we see that the width is now correctly reported, but we are also over-estimating the spacing. Fortunately, there is a third scheme for ignoring iolations, `IgnoreScheme.LARGE_FEATURE_EDGES`, which only ignores violations on the edges of _large_ features. This is actually the default choice, so if we simply call `imageruler.minimum_length_scale` without specifying an ignore scheme, this is what we will get." + "Here, we see that the width is now correctly reported, but we are also over-estimating the spacing. Fortunately, there is a third scheme for ignoring iolations, `IgnoreScheme.LARGE_FEATURE_EDGES_STRICT`, which only ignores violations on the _edges of large features_. This is actually the default choice, so if we simply call `imageruler.minimum_length_scale` without specifying an ignore scheme, this is what we will get." ] }, { @@ -139,6 +139,89 @@ "source": [ "These are the values we hoped for." ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Challenging test cases\n", + "\n", + "The various ignore schemes appear quite similar for most designs. However, some designs are problematic, such as checkerboard patterns. Here we show a checkerboard and other test designs, and the measurements reported with different ignore schemes." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = onp.asarray([[0, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]])\n", + "x = onp.kron(x, onp.ones((7, 9))).astype(bool)\n", + "\n", + "title_str = \"\"\n", + "for scheme in imageruler.IgnoreScheme:\n", + " min_width, min_spacing = imageruler.minimum_length_scale(x, ignore_scheme=scheme)\n", + " title_str += f\"{scheme.name}: {min_width=}, {min_spacing=}\\n\"\n", + "\n", + "plt.imshow(x)\n", + "_ = plt.title(title_str)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = onp.asarray([[0, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 0]])\n", + "x = onp.kron(x, onp.ones((7, 9))).astype(bool)\n", + "x[13, :] = False\n", + "\n", + "title_str = \"\"\n", + "for scheme in imageruler.IgnoreScheme:\n", + " min_width, min_spacing = imageruler.minimum_length_scale(x, ignore_scheme=scheme)\n", + " title_str += f\"{scheme.name}: {min_width=}, {min_spacing=}\\n\"\n", + "\n", + "plt.imshow(x)\n", + "_ = plt.title(title_str)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = onp.asarray([[0, 0, 0, 0], [0, 1, 1, 0], [0, 1, 1, 0], [0, 0, 0, 0]])\n", + "x = onp.kron(x, onp.ones((10, 10))).astype(bool)\n", + "\n", + "title_str = \"\"\n", + "for scheme in imageruler.IgnoreScheme:\n", + " min_width, min_spacing = imageruler.minimum_length_scale(x, ignore_scheme=scheme)\n", + " title_str += f\"{scheme.name}: {min_width=}, {min_spacing=}\\n\"\n", + "\n", + "plt.imshow(x)\n", + "_ = plt.title(title_str)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "x = onp.asarray([[0, 1, 1, 1, 0], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [1, 1, 1, 1, 1], [0, 1, 1, 1, 0]])\n", + "x = onp.pad(x, ((3, 3), (3, 3))).astype(bool)\n", + "x[2, 5] = True\n", + "\n", + "title_str = \"\"\n", + "for scheme in imageruler.IgnoreScheme:\n", + " min_width, min_spacing = imageruler.minimum_length_scale(x, ignore_scheme=scheme)\n", + " title_str += f\"{scheme.name}: {min_width=}, {min_spacing=}\\n\"\n", + "\n", + "plt.imshow(x)\n", + "_ = plt.title(title_str)" + ] } ], "metadata": { diff --git a/docs/notebooks/to_designs.ipynb b/docs/notebooks/to_designs.ipynb index 0b8f9c3..e5248d4 100644 --- a/docs/notebooks/to_designs.ipynb +++ b/docs/notebooks/to_designs.ipynb @@ -19,7 +19,7 @@ "import matplotlib.pyplot as plt\n", "from skimage import measure\n", "\n", - "design = onp.genfromtxt(\"../../reference_designs/Rasmus70nm.csv\", delimiter=\",\")\n", + "design = onp.genfromtxt(\"../../reference_designs/rgb_metalens/ex/Rasmus70nm.csv\", delimiter=\",\")\n", "design = design > 0.5 # Binarize\n", "design = onp.rot90(design)\n", "\n", diff --git a/pyproject.toml b/pyproject.toml index ced56ea..f38f611 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "imageruler" -version = "v0.2.0" +version = "v0.3.0" description = "Measure minimum sollid/void lengthscales in binary images." keywords = ["topology", "optimization", "length scale"] readme = "README.md" diff --git a/reference_designs/README.md b/reference_designs/README.md index 48d34c5..2263c8e 100644 --- a/reference_designs/README.md +++ b/reference_designs/README.md @@ -1,3 +1,2 @@ # Reference designs -This directory contains several designs which can be used to demonstrate the calculation of length scales. -- Rasmus70nm.csv: from the [RGB metalens](https://github.com/NanoComp/photonics-opt-testbed/tree/main/RGB_metalens) problem of the photonics-opt-testbed repo. +This directory contains several designs which can be used to demonstrate the calculation of length scales. They are taken from the [photonics-opt-testbed](https://github.com/NanoComp/photonics-opt-testbed/) repo. diff --git a/reference_designs/Rasmus70nm.csv b/reference_designs/rgb_metalens/ex/Rasmus70nm.csv similarity index 100% rename from reference_designs/Rasmus70nm.csv rename to reference_designs/rgb_metalens/ex/Rasmus70nm.csv diff --git a/src/imageruler/__init__.py b/src/imageruler/__init__.py index 145d023..949611f 100644 --- a/src/imageruler/__init__.py +++ b/src/imageruler/__init__.py @@ -1,6 +1,6 @@ """Imageruler for measuring minimum lengthscales in binary images.""" -__version__ = "v0.2.0" +__version__ = "v0.3.0" __all__ = [ "IgnoreScheme", "kernel_for_length_scale", diff --git a/src/imageruler/imageruler.py b/src/imageruler/imageruler.py index aff562b..a465838 100644 --- a/src/imageruler/imageruler.py +++ b/src/imageruler/imageruler.py @@ -11,8 +11,6 @@ NDArray = onp.ndarray[Any, Any] -DEFAULT_FEASIBILITY_GAP_ALLOWANCE = 10 - PLUS_3_KERNEL = onp.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=bool) PLUS_5_KERNEL = onp.array( [ @@ -26,6 +24,30 @@ ) SQUARE_3_KERNEL = onp.ones((3, 3), dtype=bool) +# Kernels for detecting horizontal and vertical edges. +N_EDGE_KERNEL = onp.array( + [ + [-1, -1, -1], + [0, 1, 0], + ], + dtype=onp.int8, +) +W_EDGE_KERNEL = onp.rot90(N_EDGE_KERNEL, k=1) +S_EDGE_KERNEL = onp.rot90(N_EDGE_KERNEL, k=2) +E_EDGE_KERNEL = onp.rot90(N_EDGE_KERNEL, k=3) + +# Kernels for detecting corners, i.e. "diagonal edges". +NE_CORNER_KERNEL = onp.array( + [ + [-1, -1], + [1, -1], + ], + dtype=onp.int8, +) +NW_CORNER_KERNEL = onp.rot90(NE_CORNER_KERNEL, k=1) +SW_CORNER_KERNEL = onp.rot90(NE_CORNER_KERNEL, k=2) +SE_CORNER_KERNEL = onp.rot90(NE_CORNER_KERNEL, k=3) + @enum.unique class IgnoreScheme(enum.Enum): @@ -39,11 +61,18 @@ class IgnoreScheme(enum.Enum): A pixel is on the edge of a large feature if it is on the edge of the feature, and adjacent to an interior pixel. Here, interior pixels are those not on any edges. + - `LARGE_FEATURE_EDGES_STRICT`: similar to `LARGE_FEATURE_EDGES`, but uses a more + strict algorithm to detect edges and does not ignore checkerboard patterns. """ NONE = "none" EDGES = "edges" LARGE_FEATURE_EDGES = "large_feature_edges" + LARGE_FEATURE_EDGES_STRICT = "large_feature_edges_strict" + + +DEFAULT_IGNORE_SCHEME = IgnoreScheme.LARGE_FEATURE_EDGES_STRICT +DEFAULT_FEASIBILITY_GAP_ALLOWANCE = 10 @enum.unique @@ -63,7 +92,7 @@ class PaddingMode(enum.Enum): def minimum_length_scale( x: NDArray, periodic: Tuple[bool, bool] = (False, False), - ignore_scheme: IgnoreScheme = IgnoreScheme.LARGE_FEATURE_EDGES, + ignore_scheme: IgnoreScheme = DEFAULT_IGNORE_SCHEME, feasibility_gap_allowance: int = DEFAULT_FEASIBILITY_GAP_ALLOWANCE, ) -> Tuple[int, int]: """Identifies the minimum length scale of solid and void features in `x`. @@ -128,7 +157,7 @@ def minimum_length_scale( def minimum_length_scale_solid( x: NDArray, periodic: Tuple[bool, bool] = (False, False), - ignore_scheme: IgnoreScheme = IgnoreScheme.LARGE_FEATURE_EDGES, + ignore_scheme: IgnoreScheme = DEFAULT_IGNORE_SCHEME, feasibility_gap_allowance: int = DEFAULT_FEASIBILITY_GAP_ALLOWANCE, ) -> int: """Identifies the minimum length scale of solid features in `x`. @@ -172,7 +201,7 @@ def length_scale_violations_solid( x: NDArray, length_scale: int, periodic: Tuple[bool, bool] = (False, False), - ignore_scheme: IgnoreScheme = IgnoreScheme.LARGE_FEATURE_EDGES, + ignore_scheme: IgnoreScheme = DEFAULT_IGNORE_SCHEME, feasibility_gap_allowance: int = DEFAULT_FEASIBILITY_GAP_ALLOWANCE, ) -> NDArray: """Computes the length scale violations, allowing for the feasibility gap. @@ -276,7 +305,7 @@ def _length_scale_violations_solid_strict( ignored = ignored_pixels(x, periodic, ignore_scheme) violations_solid = violations_solid & ~ignored - return violations_solid + return onp.asarray(violations_solid) def kernel_for_length_scale(length_scale: int) -> NDArray: @@ -327,9 +356,13 @@ def ignored_pixels( if ignore_scheme == IgnoreScheme.NONE: return onp.zeros_like(x) elif ignore_scheme == IgnoreScheme.EDGES: - return x & ~binary_erosion(x, PLUS_3_KERNEL, periodic, PaddingMode.SOLID) + return onp.asarray( + x & ~binary_erosion(x, PLUS_3_KERNEL, periodic, PaddingMode.SOLID) + ) elif ignore_scheme == IgnoreScheme.LARGE_FEATURE_EDGES: - return x & ~erode_large_features(x, periodic) + return onp.asarray(x & ~erode_large_features(x, periodic)) + elif ignore_scheme == IgnoreScheme.LARGE_FEATURE_EDGES_STRICT: + return onp.asarray(x & ~erode_large_features_strict(x, periodic)) else: raise ValueError(f"Unknown `ignore_scheme`, got {ignore_scheme}.") @@ -408,6 +441,94 @@ def _pad_width_for_kernel_shape(shape: Tuple[int, ...]) -> Tuple[_Padding, _Padd return pad_width, unpad_width +def hitmiss( + x: NDArray, + kernel: NDArray, + anchor_ij: Tuple[int, int] = (-1, -1), +) -> NDArray: + """Applies the hitmiss transformation to `x`.""" + anchor_y, anchor_x = anchor_ij + return cv2.morphologyEx( + x.view(onp.uint8), + kernel=kernel, + op=cv2.MORPH_HITMISS, + anchor=(anchor_x, anchor_y), + borderType=cv2.BORDER_REPLICATE, + ).view(bool) + + +def edges_n(x: NDArray) -> NDArray: + """Detect northeast corners of solid features.""" + return hitmiss(x, kernel=N_EDGE_KERNEL, anchor_ij=(1, 1)) & x + + +def edges_w(x: NDArray) -> NDArray: + """Detect northwest corners of solid features.""" + return hitmiss(x, kernel=W_EDGE_KERNEL, anchor_ij=(1, 1)) & x + + +def edges_s(x: NDArray) -> NDArray: + """Detect southwest corners of solid features.""" + return hitmiss(x, kernel=S_EDGE_KERNEL, anchor_ij=(0, 1)) & x + + +def edges_e(x: NDArray) -> NDArray: + """Detect southeast corners of solid features.""" + return hitmiss(x, kernel=E_EDGE_KERNEL, anchor_ij=(1, 0)) & x + + +def corners_ne(x: NDArray) -> NDArray: + """Detect northeast corners of solid features.""" + return hitmiss(x, kernel=NE_CORNER_KERNEL, anchor_ij=(1, 0)) + + +def corners_nw(x: NDArray) -> NDArray: + """Detect northwest corners of solid features.""" + return hitmiss(x, kernel=NW_CORNER_KERNEL, anchor_ij=(1, 1)) + + +def corners_sw(x: NDArray) -> NDArray: + """Detect southwest corners of solid features.""" + return hitmiss(x, kernel=SW_CORNER_KERNEL, anchor_ij=(0, 1)) + + +def corners_se(x: NDArray) -> NDArray: + """Detect southeast corners of solid features.""" + return hitmiss(x, kernel=SE_CORNER_KERNEL, anchor_ij=(0, 0)) + + +def detect_edges( + x: NDArray, + periodic: Tuple[bool, bool], +) -> NDArray: + """Idetifies edges of solid features in `x`. + + The edge of a solid feature may either be horizontal (north or south) or vertical + (east or west), or can be a corner (northeast, northwest, southeast, southwest). + + Args: + x: Bool-typed rank-2 array where corners are to be detected. + periodic: Specifies which of the two axes are to be regarded as periodic. + + Returns: + The array with identified corners. + """ + x = pad_2d( + x, pad_width=((2, 2), (2, 2)), periodic=periodic, padding_mode=PaddingMode.EDGE + ) + edges = ( + edges_n(x) + | edges_w(x) + | edges_s(x) + | edges_e(x) + | corners_ne(x) + | corners_nw(x) + | corners_sw(x) + | corners_se(x) + ) + return edges[2:-2, 2:-2] + + def erode_large_features(x: NDArray, periodic: Tuple[bool, bool]) -> NDArray: """Erodes large features while leaving small features untouched. @@ -448,6 +569,41 @@ def erode_large_features(x: NDArray, periodic: Tuple[bool, bool]) -> NDArray: return onp.asarray(x & ~should_remove) +def erode_large_features_strict(x: NDArray, periodic: Tuple[bool, bool]) -> NDArray: + """Erodes large features while leaving small features untouched. + + This function uses a more strict algorithm than `erode_large_features`, and will + not remove the corners in a checkerboard pattern. + + Args: + x: Bool-typed rank-2 array to be eroded. + periodic: Specifies which of the two axes are to be regarded as periodic. + + Returns: + The array with eroded features. + """ + assert x.dtype == bool + + neighborhood_sum = _filter_2d(x, SQUARE_3_KERNEL, periodic, PaddingMode.EDGE) + interior_pixels = neighborhood_sum == 9 + + edge_pixels = detect_edges(x, periodic=periodic) + + # Identify solid pixels that are adjacent to interior pixels. + adjacent_to_interior = ( + x + & ~interior_pixels + & binary_dilation( + x=interior_pixels, + kernel=PLUS_5_KERNEL, + periodic=periodic, + padding_mode=PaddingMode.EDGE, + ) + ) + should_remove = adjacent_to_interior & edge_pixels + return onp.asarray(x & ~should_remove) + + def _filter_2d( x: NDArray, kernel: NDArray, periodic: Tuple[bool, bool], padding_mode: PaddingMode ) -> NDArray: diff --git a/tests/test_imageruler.py b/tests/test_imageruler.py index 5980011..1cd3f71 100644 --- a/tests/test_imageruler.py +++ b/tests/test_imageruler.py @@ -148,6 +148,7 @@ def test_circle_has_expected_length_scale_ignore_edges(self, length_scale): [ imageruler.IgnoreScheme.NONE, imageruler.IgnoreScheme.LARGE_FEATURE_EDGES, + imageruler.IgnoreScheme.LARGE_FEATURE_EDGES_STRICT, ], ) ) @@ -432,6 +433,60 @@ def test_opening_matches_scipy(self, x, kernel, padding_mode): ) onp.testing.assert_array_equal(expected, actual) + def test_detect_edges(self): + x = onp.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=bool, + ) + expected = onp.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 0], + [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=bool, + ) + onp.testing.assert_array_equal( + imageruler.detect_edges(x, periodic=(False, False)), expected + ) + + def test_detect_edges_periodic(self): + x = onp.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=bool, + ) + expected = onp.array( + [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 1, 1, 0, 1, 0, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ], + dtype=bool, + ) + onp.testing.assert_array_equal( + imageruler.detect_edges(x, periodic=(True, True)), expected + ) + def test_opening_removes_small_features(self): # Test that a feature that is feasible with a size-4 brush is eliminated # by opening with the size-5 brush.