@@ -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