Skip to content

Commit 5108710

Browse files
committed
feat(invdes): smoothed projection
1 parent 2ce5542 commit 5108710

File tree

4 files changed

+207
-1
lines changed

4 files changed

+207
-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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 np.isclose(result[center_x, center_y], 1)
32+
assert np.isclose(result[0, -1], 0)
33+
assert np.isclose(result[0, 0], 0)
34+
assert np.isclose(result[-1, 0], 0)
35+
assert np.isclose(result[-1, -1], 0)
36+
37+
# fully discrete input should lead to fully discrete output
38+
discrete_result = smoothed_projection(
39+
array=arr,
40+
beta=np.inf,
41+
eta=0.5,
42+
)
43+
assert np.all(np.isclose(discrete_result, 0) | np.isclose(discrete_result, 1))
44+
45+
46+
def test_smoothed_projection_beta_non_inf():
47+
nx, ny = 50, 50
48+
arr = np.zeros((50, 50), dtype=float)
49+
50+
center_x, center_y = 25, 25
51+
radius = 10
52+
x = np.arange(nx)
53+
y = np.arange(ny)
54+
X, Y = np.meshgrid(x, y)
55+
distance = np.sqrt((X - center_x) ** 2 + (Y - center_y) ** 2)
56+
57+
arr[distance <= radius] = 1
58+
59+
# fully discrete input should still be fully discrete output
60+
discrete_result = smoothed_projection(
61+
array=arr,
62+
beta=1.0,
63+
eta=0.5,
64+
)
65+
assert np.all(np.isclose(discrete_result, 0) | np.isclose(discrete_result, 1))
66+
67+
filter = ConicFilter(kernel_size=11)
68+
arr_filtered = filter(arr)
69+
70+
smooth_result = smoothed_projection(
71+
array=arr_filtered,
72+
beta=1.0,
73+
eta=0.5,
74+
)
75+
# for sufficiently smooth input, the result should be the same as tanh projection
76+
tanh_result = tanh_projection(
77+
array=arr_filtered,
78+
beta=1.0,
79+
eta=0.5,
80+
)
81+
assert np.isclose(smooth_result, tanh_result, rtol=0, atol=1e-4).all()
82+
83+
84+
def test_smoothed_projection_initialization():
85+
# test that for initialization at eta=0.5, projection returns simply 0.5
86+
arr = np.zeros((5, 5), dtype=float) + 0.5
87+
smoothed_projection(array=arr, beta=1.0, eta=0.5)
88+
assert np.all(np.isclose(arr, 0.5))
89+
90+
91+
def test_projection_gradient():
92+
# test that gradient is finite
93+
arr = np.zeros((5, 5), dtype=float) + 0.5
94+
95+
def _helper_fn(x):
96+
return smoothed_projection(array=x, beta=1.0, eta=0.5).mean()
97+
98+
val, grad = autograd.value_and_grad(_helper_fn)(arr)
99+
assert val == 0.5
100+
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: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,107 @@ 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+
Parameters
101+
----------
102+
array : np.ndarray
103+
The input array to be projected.
104+
beta : float = BETA_DEFAULT
105+
The steepness of the projection. Higher values result in a sharper transition.
106+
eta : float = ETA_DEFAULT
107+
The midpoint of the projection.
108+
scaling_factor: float = 1.0
109+
Optional scaling factor to adjust dx and dy to different resolutions.
110+
111+
Example
112+
-------
113+
>>> import autograd.numpy as np
114+
>>> from tidy3d.plugins.autograd.invdes.filters import ConicFilter
115+
>>> arr = np.random.uniform(size=(50, 50))
116+
>>> filter = ConicFilter(kernel_size=5)
117+
>>> arr_filtered = filter(arr)
118+
>>> eta = 0.5 # center of projection
119+
>>> smoothed = smoothed_projection(arr_filtered, beta=np.inf, eta=eta)
120+
121+
.. [1] A. M. Hammond, A. Oskooi, I. M. Hammond, M. Chen, S. E. Ralph, and
122+
S. G. Johnson, "Unifying and accelerating level-set and density-based topology
123+
optimization by subpixel-smoothed projection," arXiv:2503.20189v3 [physics.optics]
124+
(2025).
125+
"""
126+
# sanity checks
127+
assert array.ndim == 2
128+
if np.isinf(beta) and np.any(np.isclose(array, eta)):
129+
raise Exception(
130+
"For beta of infinity no value in the array should be close to eta, otherwise nan-values will emerge"
131+
)
132+
# smoothing kernel is circle (or ellipse for non-uniform grid)
133+
# we choose smoothing kernel with unit area, which is r~=0.56, a bit larger than (arbitrary) default r=0.55 in paper
134+
dx = dy = scaling_factor
135+
smooth_radius = np.sqrt(1 / np.pi) * scaling_factor
136+
137+
original_projected = tanh_projection(array, beta=beta, eta=eta)
138+
139+
# finite-difference spatial gradients
140+
rho_filtered_grad = np.gradient(array)
141+
rho_filtered_grad_helper = (rho_filtered_grad[0] / dx) ** 2 + (rho_filtered_grad[1] / dy) ** 2
142+
143+
nonzero_norm = np.abs(rho_filtered_grad_helper) > 1e-10
144+
145+
filtered_grad_norm = np.sqrt(np.where(nonzero_norm, rho_filtered_grad_helper, 1))
146+
filtered_grad_norm_eff = np.where(nonzero_norm, filtered_grad_norm, 1)
147+
148+
# distance of pixel center to nearest interface
149+
distance = (eta - array) / filtered_grad_norm_eff
150+
151+
needs_smoothing = nonzero_norm & (np.abs(distance) < smooth_radius)
152+
153+
# double where trick
154+
d_rel = distance / smooth_radius
155+
polynom = np.where(
156+
needs_smoothing, 0.5 - 15 / 16 * d_rel + 5 / 8 * d_rel**3 - 3 / 16 * d_rel**5, 1.0
157+
)
158+
# F(-d)
159+
polynom_neg = np.where(
160+
needs_smoothing, 0.5 + 15 / 16 * d_rel - 5 / 8 * d_rel**3 + 3 / 16 * d_rel**5, 1.0
161+
)
162+
163+
# two projections, one for lower and one for upper bound
164+
rho_filtered_minus = array - smooth_radius * filtered_grad_norm_eff * polynom
165+
rho_filtered_plus = array + smooth_radius * filtered_grad_norm_eff * polynom_neg
166+
rho_minus_eff_projected = tanh_projection(rho_filtered_minus, beta=beta, eta=eta)
167+
rho_plus_eff_projected = tanh_projection(rho_filtered_plus, beta=beta, eta=eta)
168+
169+
# Smoothing is only applied to projections
170+
projected_smoothed = (1 - polynom) * rho_minus_eff_projected + polynom * rho_plus_eff_projected
171+
smoothed = np.where(
172+
needs_smoothing,
173+
projected_smoothed,
174+
original_projected,
175+
)
176+
return smoothed

0 commit comments

Comments
 (0)