Skip to content

Commit b02286a

Browse files
committed
feat(invdes): smoothed projection
1 parent 81febf5 commit b02286a

File tree

4 files changed

+220
-1
lines changed

4 files changed

+220
-1
lines changed

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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
import autograd
4+
import numpy as np
5+
import pytest
6+
7+
from tidy3d.plugins.autograd.invdes.filters import ConicFilter
8+
from tidy3d.plugins.autograd.invdes.projections import smoothed_projection, tanh_projection
9+
10+
11+
def test_smoothed_projection_beta_inf():
12+
nx, ny = 50, 50
13+
arr = np.zeros((50, 50), dtype=float)
14+
15+
center_x, center_y = 25, 25
16+
radius = 10
17+
x = np.arange(nx)
18+
y = np.arange(ny)
19+
X, Y = np.meshgrid(x, y)
20+
distance = np.sqrt((X - center_x) ** 2 + (Y - center_y) ** 2)
21+
22+
arr[distance <= radius] = 1
23+
24+
filter = ConicFilter(kernel_size=5)
25+
arr_filtered = filter(arr)
26+
27+
result = smoothed_projection(
28+
array=arr_filtered,
29+
beta=np.inf,
30+
eta=0.5,
31+
)
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+
with pytest.raises(ValueError):
92+
result = smoothed_projection(array=arr, beta=np.inf, eta=0.5)
93+
94+
95+
def test_projection_gradient():
96+
# test that gradient is finite
97+
arr = np.zeros((5, 5), dtype=float) + 0.5
98+
99+
def _helper_fn(x):
100+
return smoothed_projection(array=x, beta=1.0, eta=0.5).mean()
101+
102+
val, grad = autograd.value_and_grad(_helper_fn)(arr)
103+
assert val == 0.5
104+
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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,116 @@ 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 would produce NaN values if
107+
the input is equal to ``eta``. In this case an error is raised.
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+
assert array.ndim == 2
137+
if np.isinf(beta) and np.any(array == eta):
138+
raise ValueError(
139+
"For beta of infinity no value in the array should be close to eta, otherwise nan-values will emerge"
140+
)
141+
# smoothing kernel is circle (or ellipse for non-uniform grid)
142+
# we choose smoothing kernel with unit area, which is r~=0.56, a bit larger than (arbitrary) default r=0.55 in paper
143+
dx = dy = scaling_factor
144+
smooth_radius = np.sqrt(1 / np.pi) * scaling_factor
145+
146+
original_projected = tanh_projection(array, beta=beta, eta=eta)
147+
148+
# finite-difference spatial gradients
149+
rho_filtered_grad = np.gradient(array)
150+
rho_filtered_grad_helper = (rho_filtered_grad[0] / dx) ** 2 + (rho_filtered_grad[1] / dy) ** 2
151+
152+
nonzero_norm = np.abs(rho_filtered_grad_helper) > 1e-10
153+
154+
filtered_grad_norm = np.sqrt(np.where(nonzero_norm, rho_filtered_grad_helper, 1))
155+
filtered_grad_norm_eff = np.where(nonzero_norm, filtered_grad_norm, 1)
156+
157+
# distance of pixel center to nearest interface
158+
distance = (eta - array) / filtered_grad_norm_eff
159+
160+
needs_smoothing = nonzero_norm & (np.abs(distance) < smooth_radius)
161+
162+
# double where trick
163+
d_rel = distance / smooth_radius
164+
polynom = np.where(
165+
needs_smoothing, 0.5 - 15 / 16 * d_rel + 5 / 8 * d_rel**3 - 3 / 16 * d_rel**5, 1.0
166+
)
167+
# F(-d)
168+
polynom_neg = np.where(
169+
needs_smoothing, 0.5 + 15 / 16 * d_rel - 5 / 8 * d_rel**3 + 3 / 16 * d_rel**5, 1.0
170+
)
171+
172+
# two projections, one for lower and one for upper bound
173+
rho_filtered_minus = array - smooth_radius * filtered_grad_norm_eff * polynom
174+
rho_filtered_plus = array + smooth_radius * filtered_grad_norm_eff * polynom_neg
175+
rho_minus_eff_projected = tanh_projection(rho_filtered_minus, beta=beta, eta=eta)
176+
rho_plus_eff_projected = tanh_projection(rho_filtered_plus, beta=beta, eta=eta)
177+
178+
# Smoothing is only applied to projections
179+
projected_smoothed = (1 - polynom) * rho_minus_eff_projected + polynom * rho_plus_eff_projected
180+
smoothed = np.where(
181+
needs_smoothing,
182+
projected_smoothed,
183+
original_projected,
184+
)
185+
return smoothed

0 commit comments

Comments
 (0)