Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `DirectivityMonitorSpec` for automated creation and configuration of directivity radiation monitors in `TerminalComponentModeler`.
- Added multimode support to `WavePort` in the smatrix plugin, allowing multiple modes to be analyzed per port.
- Added support for `.lydrc` files for design rule checking in the `klayout` plugin.
- Added a Gaussian inverse design filter option with autograd gradients and complete padding mode coverage.

### Breaking Changes
- Edge singularity correction at PEC and lossy metal edges defaults to `True`.
Expand Down
16 changes: 15 additions & 1 deletion tests/test_plugins/autograd/invdes/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
make_circular_filter,
make_conic_filter,
make_filter,
make_gaussian_filter,
)
from tidy3d.plugins.autograd.types import PaddingType

Expand Down Expand Up @@ -41,7 +42,7 @@ def test_get_kernel_size_invalid_arguments():
@pytest.mark.parametrize("normalize", [True, False])
@pytest.mark.parametrize("padding", PaddingType.__args__)
class TestMakeFilter:
@pytest.mark.parametrize("filter_type", ["circular", "conic"])
@pytest.mark.parametrize("filter_type", ["circular", "conic", "gaussian"])
def test_make_filter(self, rng, filter_type, radius, dl, size_px, normalize, padding):
"""Test make_filter function for various parameters."""
filter_func = make_filter(
Expand Down Expand Up @@ -81,3 +82,16 @@ def test_make_conic_filter(self, rng, radius, dl, size_px, normalize, padding):
array = rng.random((51, 51))
result = filter_func(array)
assert result.shape == array.shape

def test_make_gaussian_filter(self, rng, radius, dl, size_px, normalize, padding):
"""Test make_gaussian_filter function for various parameters."""
filter_func = make_gaussian_filter(
radius=radius,
dl=dl,
size_px=size_px,
normalize=normalize,
padding=padding,
)
array = rng.random((51, 51))
result = filter_func(array)
assert result.shape == array.shape
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@pytest.mark.parametrize("radius", [1, 2, (1, 2)])
@pytest.mark.parametrize("dl", [0.1, 0.2, (0.1, 0.2)])
@pytest.mark.parametrize("size_px", [None, 5, (5, 7)])
@pytest.mark.parametrize("filter_type", ["circular", "conic"])
@pytest.mark.parametrize("filter_type", ["circular", "conic", "gaussian"])
@pytest.mark.parametrize("padding", PaddingType.__args__)
def test_make_filter_and_project(rng, radius, dl, size_px, filter_type, padding):
"""Test make_filter_and_project function for various parameters."""
Expand Down
13 changes: 2 additions & 11 deletions tests/test_plugins/autograd/primitives/test_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,10 @@
@pytest.mark.parametrize("size", [10, 11])
@pytest.mark.parametrize("ndim", [1, 2, 3])
@pytest.mark.parametrize("sigma", [1, 2])
@pytest.mark.parametrize(
"mode",
[
"constant",
"reflect",
"wrap",
pytest.param("nearest", marks=pytest.mark.skip(reason="Grads not implemented.")),
pytest.param("mirror", marks=pytest.mark.skip(reason="Grads not implemented.")),
],
)
@pytest.mark.parametrize("mode", ["constant", "nearest", "mirror", "reflect", "wrap"])
def test_gaussian_filter_grad(rng, size, ndim, sigma, mode):
x = rng.random((size,) * ndim)
check_grads(lambda x: gaussian_filter(x, sigma=sigma, mode=mode), modes=["rev"], order=2)(x)
check_grads(lambda x: gaussian_filter(x, sigma=sigma, mode=mode), modes=["rev"], order=1)(x)


@pytest.mark.parametrize("shape, axis", [((100,), -1), ((10, 12), 0), ((10, 12), 1)])
Expand Down
4 changes: 4 additions & 0 deletions tidy3d/plugins/autograd/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ConicFilter,
ErosionDilationPenalty,
FilterAndProject,
GaussianFilter,
grey_indicator,
initialize_params_from_simulation,
make_circular_filter,
Expand All @@ -33,6 +34,7 @@
make_erosion_dilation_penalty,
make_filter,
make_filter_and_project,
make_gaussian_filter,
ramp_projection,
tanh_projection,
)
Expand All @@ -44,6 +46,7 @@
"ConicFilter",
"ErosionDilationPenalty",
"FilterAndProject",
"GaussianFilter",
"add_at",
"chain",
"convolve",
Expand All @@ -65,6 +68,7 @@
"make_erosion_dilation_penalty",
"make_filter",
"make_filter_and_project",
"make_gaussian_filter",
"make_kernel",
"morphological_gradient",
"morphological_gradient_external",
Expand Down
4 changes: 4 additions & 0 deletions tidy3d/plugins/autograd/invdes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from .filters import (
CircularFilter,
ConicFilter,
GaussianFilter,
make_circular_filter,
make_conic_filter,
make_filter,
make_gaussian_filter,
)
from .misc import grey_indicator
from .parametrizations import (
Expand All @@ -21,6 +23,7 @@
"ConicFilter",
"ErosionDilationPenalty",
"FilterAndProject",
"GaussianFilter",
"grey_indicator",
"initialize_params_from_simulation",
"make_circular_filter",
Expand All @@ -29,6 +32,7 @@
"make_erosion_dilation_penalty",
"make_filter",
"make_filter_and_project",
"make_gaussian_filter",
"ramp_projection",
"tanh_projection",
]
92 changes: 86 additions & 6 deletions tidy3d/plugins/autograd/invdes/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,19 @@
from tidy3d.components.base import Tidy3dBaseModel
from tidy3d.components.types import TYPE_TAG_STR
from tidy3d.plugins.autograd.functions import convolve
from tidy3d.plugins.autograd.primitives import gaussian_filter as autograd_gaussian_filter
from tidy3d.plugins.autograd.types import KernelType, PaddingType
from tidy3d.plugins.autograd.utilities import get_kernel_size_px, make_kernel

_GAUSSIAN_SIGMA_SCALE = 0.445 # empirically matches conic kernel response in 1D/2D tests
_GAUSSIAN_PADDING_MAP = {
"constant": "constant",
"edge": "nearest",
"reflect": "reflect",
"symmetric": "mirror",
"wrap": "wrap",
}


class AbstractFilter(Tidy3dBaseModel, abc.ABC):
"""An abstract class for creating and applying convolution filters."""
Expand Down Expand Up @@ -92,9 +102,13 @@ def __call__(self, array: NDArray) -> NDArray:
size_px = tuple(np.atleast_1d(self.kernel_size))
if len(size_px) != squeezed_array.ndim:
size_px *= squeezed_array.ndim
filtered_array = self._apply_filter(squeezed_array, size_px)
return np.reshape(filtered_array, original_shape)

def _apply_filter(self, array: NDArray, size_px: tuple[int, ...]) -> NDArray:
"""Apply the concrete filter implementation to the squeezed array."""
kernel = self.get_kernel(size_px, self.normalize)
convolved_array = convolve(squeezed_array, kernel, padding=self.padding)
return np.reshape(convolved_array, original_shape)
return convolve(array, kernel, padding=self.padding)


class ConicFilter(AbstractFilter):
Expand Down Expand Up @@ -127,6 +141,60 @@ def get_kernel(size_px: Iterable[int], normalize: bool) -> NDArray:
return make_kernel(kernel_type="circular", size=size_px, normalize=normalize)


class GaussianFilter(AbstractFilter):
"""A Gaussian filter implemented via separable gaussian_filter primitive.

Notes
-----
Padding modes ``'constant'``, ``'edge'``, ``'reflect'``, ``'symmetric'``, and ``'wrap'`` are
supported. Modes ``'edge'`` and ``'symmetric'`` are internally mapped to the SciPy equivalents
``'nearest'`` and ``'mirror'`` respectively. The default ``sigma_scale`` of 0.445 was tuned to
match the conic kernel when expressed in pixel radius. The ``normalize`` flag inherited from
:class:`AbstractFilter` is ignored because the separable Gaussian implementation always returns
a unit-sum kernel; setting it to ``False`` has no effect.
"""

sigma_scale: float = pd.Field(
_GAUSSIAN_SIGMA_SCALE,
title="Sigma Scale",
description="Scale factor mapping radius in pixels to Gaussian sigma.",
ge=0.0,
)
truncate: float = pd.Field(
2.0,
title="Truncate",
description="Truncation radius in multiples of sigma passed to ``gaussian_filter``.",
ge=0.0,
)

@staticmethod
def get_kernel(size_px: Iterable[int], normalize: bool) -> NDArray:
raise NotImplementedError("GaussianFilter does not build an explicit kernel.")

def _apply_filter(self, array: NDArray, size_px: tuple[int, ...]) -> NDArray:
radius_px = np.maximum((np.array(size_px, dtype=float) - 1.0) / 2.0, 0.0)
if radius_px.size == 0:
return array

mode = _GAUSSIAN_PADDING_MAP.get(self.padding)
if mode is None:
raise ValueError(
f"Unsupported padding mode '{self.padding}' for gaussian filter; "
f"supported modes are {tuple(_GAUSSIAN_PADDING_MAP)}."
)

sigma = tuple(float(self.sigma_scale * r) if r > 0 else 0.0 for r in radius_px)
if not any(sigma):
return array

kwargs: dict[str, Any] = {"mode": mode, "truncate": float(self.truncate)}
if mode == "constant":
kwargs["cval"] = 0.0

filtered = autograd_gaussian_filter(array, sigma=sigma, **kwargs)
return filtered


def _get_kernel_size(
radius: Union[float, tuple[float, ...]],
dl: Union[float, tuple[float, ...]],
Expand Down Expand Up @@ -189,7 +257,7 @@ def make_filter(
padding : PaddingType = "reflect"
The padding mode to use.
filter_type : KernelType
The type of kernel to create (``circular`` or ``conic``).
The type of kernel to create (``circular``, ``conic``, or ``gaussian``).

Returns
-------
Expand All @@ -202,10 +270,12 @@ def make_filter(
filter_class = ConicFilter
elif filter_type == "circular":
filter_class = CircularFilter
elif filter_type == "gaussian":
filter_class = GaussianFilter
else:
raise ValueError(
f"Unsupported filter_type: {filter_type}. "
"Must be one of `CircularFilter` or `ConicFilter`."
"Must be one of `CircularFilter`, `ConicFilter`, or `GaussianFilter`."
)

filter_instance = filter_class(kernel_size=kernel_size, normalize=normalize, padding=padding)
Expand All @@ -221,11 +291,21 @@ def make_filter(
"""

make_circular_filter = partial(make_filter, filter_type="circular")
make_circular_filter.__doc__ = """make_filter() with a default filter_type value of `circular`.
make_circular_filter.__doc__ = """make_filter() with a default filter_type value of ``circular``.

See Also
--------
:func:`~filters.make_filter` : Function to create a filter based on the specified kernel type and size.
"""

make_gaussian_filter = partial(make_filter, filter_type="gaussian")
make_gaussian_filter.__doc__ = """make_filter() with a default filter_type value of ``gaussian``.

See Also
--------
:func:`~filters.make_filter` : Function to create a filter based on the specified kernel type and size.
"""

FilterType = Annotated[Union[ConicFilter, CircularFilter], pd.Field(discriminator=TYPE_TAG_STR)]
FilterType = Annotated[
Union[ConicFilter, CircularFilter, GaussianFilter], pd.Field(discriminator=TYPE_TAG_STR)
]
Loading