Skip to content

Commit 5882680

Browse files
committed
feat(invdes): smoothed projection
1 parent 0547714 commit 5882680

File tree

5 files changed

+216
-1
lines changed

5 files changed

+216
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
- Added warning if port mesh refinement is incompatible with the `GridSpec` in the `TerminalComponentModeler`.
4040
- Various types, e.g. different `Simulation` or `SimulationData` sub-classes, can be loaded from file directly with `Tidy3dBaseModel.from_file()`.
4141
- Added `interp_spec` in `EMEModeSpec` to enable faster multi-frequency EME simulations. Note that the default is now `ModeInterpSpec.cheb(num_points=3, reduce_data=True)`; previously the computation was repeated at all frequencies.
42+
- Added `smoothed_projection` for topology optimization of completely binarized designs.
4243

4344
### Breaking Changes
4445
- Edge singularity correction at PEC and lossy metal edges defaults to `True`.

docs/api/plugins/autograd.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,4 +84,5 @@ Inverse Design
8484
tidy3d.plugins.autograd.invdes.make_filter_and_project
8585
tidy3d.plugins.autograd.invdes.ramp_projection
8686
tidy3d.plugins.autograd.invdes.tanh_projection
87+
tidy3d.plugins.autograd.invdes.smoothed_projection
8788

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
import autograd
4+
import numpy as np
5+
6+
from tidy3d.plugins.autograd.invdes.filters import ConicFilter
7+
from tidy3d.plugins.autograd.invdes.projections import smoothed_projection, tanh_projection
8+
9+
10+
def test_smoothed_projection_beta_inf():
11+
nx, ny = 50, 50
12+
arr = np.zeros((50, 50), dtype=float)
13+
14+
center_x, center_y = 25, 25
15+
radius = 10
16+
x = np.arange(nx)
17+
y = np.arange(ny)
18+
X, Y = np.meshgrid(x, y)
19+
distance = np.sqrt((X - center_x) ** 2 + (Y - center_y) ** 2)
20+
21+
arr[distance <= radius] = 1
22+
23+
filter = ConicFilter(kernel_size=5)
24+
arr_filtered = filter(arr)
25+
26+
result = smoothed_projection(
27+
array=arr_filtered,
28+
beta=np.inf,
29+
eta=0.5,
30+
)
31+
assert not np.any(np.isinf(result) | np.isnan(result))
32+
assert np.isclose(result[center_x, center_y], 1)
33+
assert np.isclose(result[0, -1], 0)
34+
assert np.isclose(result[0, 0], 0)
35+
assert np.isclose(result[-1, 0], 0)
36+
assert np.isclose(result[-1, -1], 0)
37+
38+
# fully discrete input should lead to fully discrete output
39+
discrete_result = smoothed_projection(
40+
array=arr,
41+
beta=np.inf,
42+
eta=0.5,
43+
)
44+
assert np.all(np.isclose(discrete_result, 0) | np.isclose(discrete_result, 1))
45+
46+
47+
def test_smoothed_projection_beta_non_inf():
48+
nx, ny = 50, 50
49+
arr = np.zeros((50, 50), dtype=float)
50+
51+
center_x, center_y = 25, 25
52+
radius = 10
53+
x = np.arange(nx)
54+
y = np.arange(ny)
55+
X, Y = np.meshgrid(x, y)
56+
distance = np.sqrt((X - center_x) ** 2 + (Y - center_y) ** 2)
57+
58+
arr[distance <= radius] = 1
59+
60+
# fully discrete input should still be fully discrete output
61+
discrete_result = smoothed_projection(
62+
array=arr,
63+
beta=1.0,
64+
eta=0.5,
65+
)
66+
assert np.all(np.isclose(discrete_result, 0) | np.isclose(discrete_result, 1))
67+
68+
filter = ConicFilter(kernel_size=11)
69+
arr_filtered = filter(arr)
70+
71+
smooth_result = smoothed_projection(
72+
array=arr_filtered,
73+
beta=1.0,
74+
eta=0.5,
75+
)
76+
# for sufficiently smooth input, the result should be the same as tanh projection
77+
tanh_result = tanh_projection(
78+
array=arr_filtered,
79+
beta=1.0,
80+
eta=0.5,
81+
)
82+
assert np.isclose(smooth_result, tanh_result, rtol=0, atol=1e-4).all()
83+
84+
85+
def test_smoothed_projection_initialization():
86+
# test that for initialization at eta=0.5, projection returns simply 0.5
87+
arr = np.zeros((5, 5), dtype=float) + 0.5
88+
result = smoothed_projection(array=arr, beta=1.0, eta=0.5)
89+
assert np.all(np.isclose(result, 0.5))
90+
91+
92+
def test_projection_gradient():
93+
# test that gradient is finite
94+
arr = np.zeros((5, 5), dtype=float) + 0.5
95+
96+
def _helper_fn(x):
97+
return smoothed_projection(array=x, beta=1.0, eta=0.5).mean()
98+
99+
val, grad = autograd.value_and_grad(_helper_fn)(arr)
100+
assert val == 0.5
101+
assert np.all(~(np.isnan(grad) | np.isinf(grad)))

tidy3d/plugins/autograd/invdes/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
make_filter_and_project,
1717
)
1818
from .penalties import ErosionDilationPenalty, make_curvature_penalty, make_erosion_dilation_penalty
19-
from .projections import ramp_projection, tanh_projection
19+
from .projections import ramp_projection, smoothed_projection, tanh_projection
2020

2121
__all__ = [
2222
"CircularFilter",
@@ -34,5 +34,6 @@
3434
"make_filter_and_project",
3535
"make_gaussian_filter",
3636
"ramp_projection",
37+
"smoothed_projection",
3738
"tanh_projection",
3839
]

tidy3d/plugins/autograd/invdes/projections.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,114 @@ def tanh_projection(
7070
num = np.tanh(beta * eta) + np.tanh(beta * (array - eta))
7171
denom = np.tanh(beta * eta) + np.tanh(beta * (1 - eta))
7272
return num / denom
73+
74+
75+
def smoothed_projection(
76+
array: NDArray,
77+
beta: float = BETA_DEFAULT,
78+
eta: float = ETA_DEFAULT,
79+
scaling_factor=1.0,
80+
) -> NDArray:
81+
"""
82+
Apply a subpixel-smoothed projection method.
83+
The subpixel-smoothed projection method is discussed in [1]_ as follows:
84+
85+
This projection method eliminates discontinuities by applying first-order
86+
smoothing at material boundaries through analytical fill factors. Unlike
87+
traditional quadrature approaches, it works with maximum projection strength
88+
(:math:`\\beta = \\infty`) and derives closed-form expressions for interfacial regions.
89+
90+
Prerequisites: input fields must be pre-filtered for continuity (for example
91+
using a conic filter).
92+
93+
The algorithm detects whether boundaries intersect grid cells. When interfaces
94+
are absent, standard projection is applied. For cells containing boundaries,
95+
analytical fill ratios are computed to maintain gradient continuity as interfaces
96+
move through cells and traverse pixel centers. This enables arbitrarily large
97+
:math:`\\beta` values while preserving differentiability throughout the transition
98+
process.
99+
100+
.. warning::
101+
This function assumes that the device is placed on a uniform grid. When using
102+
```GridSpec.auto``` in the simulation, make sure to place a ``MeshOverrideStructure`` at
103+
the position of the optimized geometry.
104+
105+
.. warning::
106+
When using :math:`\\beta = \\infty` the function will produce NaN values if
107+
the input is exactly equal to ``eta``.
108+
109+
Parameters
110+
----------
111+
array : np.ndarray
112+
The input array to be projected.
113+
beta : float = BETA_DEFAULT
114+
The steepness of the projection. Higher values result in a sharper transition.
115+
eta : float = ETA_DEFAULT
116+
The midpoint of the projection.
117+
scaling_factor: float = 1.0
118+
Optional scaling factor to adjust dx and dy to different resolutions.
119+
120+
Example
121+
-------
122+
>>> import autograd.numpy as np
123+
>>> from tidy3d.plugins.autograd.invdes.filters import ConicFilter
124+
>>> arr = np.random.uniform(size=(50, 50))
125+
>>> filter = ConicFilter(kernel_size=5)
126+
>>> arr_filtered = filter(arr)
127+
>>> eta = 0.5 # center of projection
128+
>>> smoothed = smoothed_projection(arr_filtered, beta=np.inf, eta=eta)
129+
130+
.. [1] A. M. Hammond, A. Oskooi, I. M. Hammond, M. Chen, S. E. Ralph, and
131+
S. G. Johnson, "Unifying and accelerating level-set and density-based topology
132+
optimization by subpixel-smoothed projection," arXiv:2503.20189v3 [physics.optics]
133+
(2025).
134+
"""
135+
# sanity checks
136+
if array.ndim != 2:
137+
raise ValueError(f"Smoothed projection expects a 2d-array, but got shape {array.shape=}")
138+
139+
# smoothing kernel is circle (or ellipse for non-uniform grid)
140+
# we choose smoothing kernel with unit area, which is r~=0.56, a bit larger than (arbitrary) default r=0.55 in paper
141+
dx = dy = scaling_factor
142+
smooth_radius = np.sqrt(1 / np.pi) * scaling_factor
143+
144+
original_projected = tanh_projection(array, beta=beta, eta=eta)
145+
146+
# finite-difference spatial gradients
147+
rho_filtered_grad = np.gradient(array)
148+
rho_filtered_grad_helper = (rho_filtered_grad[0] / dx) ** 2 + (rho_filtered_grad[1] / dy) ** 2
149+
150+
nonzero_norm = np.abs(rho_filtered_grad_helper) > 1e-10
151+
152+
filtered_grad_norm = np.sqrt(np.where(nonzero_norm, rho_filtered_grad_helper, 1))
153+
filtered_grad_norm_eff = np.where(nonzero_norm, filtered_grad_norm, 1)
154+
155+
# distance of pixel center to nearest interface
156+
distance = (eta - array) / filtered_grad_norm_eff
157+
158+
needs_smoothing = nonzero_norm & (np.abs(distance) < smooth_radius)
159+
160+
# double where trick
161+
d_rel = distance / smooth_radius
162+
polynom = np.where(
163+
needs_smoothing, 0.5 - 15 / 16 * d_rel + 5 / 8 * d_rel**3 - 3 / 16 * d_rel**5, 1.0
164+
)
165+
# F(-d)
166+
polynom_neg = np.where(
167+
needs_smoothing, 0.5 + 15 / 16 * d_rel - 5 / 8 * d_rel**3 + 3 / 16 * d_rel**5, 1.0
168+
)
169+
170+
# two projections, one for lower and one for upper bound
171+
rho_filtered_minus = array - smooth_radius * filtered_grad_norm_eff * polynom
172+
rho_filtered_plus = array + smooth_radius * filtered_grad_norm_eff * polynom_neg
173+
rho_minus_eff_projected = tanh_projection(rho_filtered_minus, beta=beta, eta=eta)
174+
rho_plus_eff_projected = tanh_projection(rho_filtered_plus, beta=beta, eta=eta)
175+
176+
# Smoothing is only applied to projections
177+
projected_smoothed = (1 - polynom) * rho_minus_eff_projected + polynom * rho_plus_eff_projected
178+
smoothed = np.where(
179+
needs_smoothing,
180+
projected_smoothed,
181+
original_projected,
182+
)
183+
return smoothed

0 commit comments

Comments
 (0)