|
| 1 | +""" |
| 2 | +These are the core algorithms and routines behind SSP. |
| 3 | +""" |
| 4 | + |
| 5 | +from jax import numpy as jnp |
| 6 | + |
| 7 | +from .utils import ArrayLikeType, tanh_projection |
| 8 | + |
| 9 | +# Naively we might pick a smoothing radius of exactly 0.5 (such that the diameter |
| 10 | +# of the smoothing kernel spans the full pixel/voxel). However, in the case when |
| 11 | +# a geometry interface is defined _right_ on the edge of a voxel (which often |
| 12 | +# happens when the user initializes a geometry with a specific design), no smoothing |
| 13 | +# occurs. By extending the kernel slightly beyond the edge of the pixel, we account |
| 14 | +# for these corner cases. |
| 15 | +DEFAULT_SMOOTHING_RADIUS = 0.55 |
| 16 | + |
| 17 | +def ssp1_bilinear( |
| 18 | + rho_filtered: ArrayLikeType, |
| 19 | + beta: float, |
| 20 | + eta: float, |
| 21 | + resolution: float, |
| 22 | + smoothing_radius: float = DEFAULT_SMOOTHING_RADIUS |
| 23 | +): |
| 24 | + """Project using the original SSP1 algorithm with bilinear interpolation. |
| 25 | +
|
| 26 | + This technique integrates out the discontinuity within the projection |
| 27 | + function, allowing the user to smoothly increase β from 0 to ∞ without |
| 28 | + losing the gradient. Effectively, a level set is created, and from this |
| 29 | + level set, SSP1 bilinear subpixel smoothing is applied to the interfaces (if |
| 30 | + any are present). |
| 31 | +
|
| 32 | + In order for this to work, the input array must already be smooth (e.g. by |
| 33 | + filtering). |
| 34 | +
|
| 35 | + While the original approach involves numerical quadrature, this approach |
| 36 | + performs a "trick" by assuming that the user is always infinitely projecting |
| 37 | + (β=∞). In this case, the expensive quadrature simplifies to an analytic |
| 38 | + fill-factor expression. When to use this fill factor requires some careful |
| 39 | + logic. |
| 40 | +
|
| 41 | + For one, we want to make sure that the user can indeed project at any level |
| 42 | + (not just infinity). So in these cases, we simply check if in interface is |
| 43 | + within the pixel. If not, we revert to the standard filter plus project |
| 44 | + technique. |
| 45 | +
|
| 46 | + If there is an interface, we want to make sure the derivative remains |
| 47 | + continuous both as the interface leaves the cell, *and* as it crosses the |
| 48 | + center. To ensure this, we need to account for the different possibilities. |
| 49 | +
|
| 50 | + Ref: A. M. Hammond, A. Oskooi, I. M. Hammond, M. Chen, S. E. Ralph, and |
| 51 | + S. G. Johnson, “Unifying and accelerating level-set and density-based topology |
| 52 | + optimization by subpixel-smoothed projection,” Optics Express, vol. 33, |
| 53 | + pp. 33620-33642, July 2025. Editor's Pick. |
| 54 | +
|
| 55 | + Args: |
| 56 | + rho_filtered: The (2D) input design parameters (already filered e.g. |
| 57 | + with a conic filter). |
| 58 | + beta: The thresholding parameter in the range [0, inf]. Determines the |
| 59 | + degree of binarization of the output. |
| 60 | + eta: The threshold point in the range [0, 1]. |
| 61 | + resolution: resolution of the design grid. |
| 62 | + smoothing_radius: The smoothing radius of the kernel relative to the |
| 63 | + pixel/voxel "width." |
| 64 | + Returns: |
| 65 | + The projected and smoothed output. |
| 66 | +
|
| 67 | + Example: |
| 68 | + >>> Lx = 2; Ly = 2 |
| 69 | + >>> resolution = 50 |
| 70 | + >>> eta_i = 0.5; eta_e = 0.75 |
| 71 | + >>> lengthscale = 0.1 |
| 72 | + >>> filter_radius = get_conic_radius_from_eta_e(lengthscale, eta_e) |
| 73 | + >>> Nx = onp.round(Lx * resolution) + 1 |
| 74 | + >>> Ny = onp.round(Ly * resolution) + 1 |
| 75 | + >>> rho = onp.random.rand(Nx, Ny) |
| 76 | + >>> beta = npa.inf |
| 77 | + >>> rho_filtered = conic_filter(rho, filter_radius, Lx, Ly, resolution) |
| 78 | + >>> rho_projected = smoothed_projection(rho_filtered, beta, eta_i, resolution) |
| 79 | + """ |
| 80 | + # TODO [alechammond] The current implementation is ported from meep |
| 81 | + # and only supports 2D inputs. We'll want to generalize this to |
| 82 | + # arbitrary dimensions. |
| 83 | + |
| 84 | + # TODO [alechammond] Note that currently, the underlying assumption |
| 85 | + # is that the smoothing kernel is a circle, which means dx = dy. |
| 86 | + dx = dy = 1 / resolution |
| 87 | + R_smoothing = smoothing_radius * dx |
| 88 | + |
| 89 | + rho_projected = tanh_projection(rho_filtered, beta=beta, eta=eta) |
| 90 | + |
| 91 | + # Compute the spatial gradient (using finite differences) of the *filtered* |
| 92 | + # field, which will always be smooth and is the key to our approach. This |
| 93 | + # gradient essentially represents the normal direction pointing the the |
| 94 | + # nearest inteface. |
| 95 | + rho_filtered_grad = jnp.gradient(rho_filtered) |
| 96 | + rho_filtered_grad_helper = (rho_filtered_grad[0] / dx) ** 2 + ( |
| 97 | + rho_filtered_grad[1] / dy |
| 98 | + ) ** 2 |
| 99 | + |
| 100 | + # Note that a uniform field (norm=0) is problematic, because it creates |
| 101 | + # divide by zero issues and makes backpropagation difficult, so we sanitize |
| 102 | + # and determine where smoothing is actually needed. The value where we don't |
| 103 | + # need smoothings doesn't actually matter, since all our computations our |
| 104 | + # purely element-wise (no spatial locality) and those pixels will instead |
| 105 | + # rely on the standard projection. So just use 1, since it's well behaved. |
| 106 | + nonzero_norm = jnp.abs(rho_filtered_grad_helper) > 0 |
| 107 | + |
| 108 | + rho_filtered_grad_norm = jnp.sqrt( |
| 109 | + jnp.where(nonzero_norm, rho_filtered_grad_helper, 1) |
| 110 | + ) |
| 111 | + rho_filtered_grad_norm_eff = jnp.where(nonzero_norm, rho_filtered_grad_norm, 1) |
| 112 | + |
| 113 | + # The distance for the center of the pixel to the nearest interface |
| 114 | + d = (eta - rho_filtered) / rho_filtered_grad_norm_eff |
| 115 | + |
| 116 | + # Only need smoothing if an interface lies within the voxel. Since d is |
| 117 | + # actually an "effective" d by this point, we need to ignore values that may |
| 118 | + # have been sanitized earlier on. |
| 119 | + needs_smoothing = nonzero_norm & (jnp.abs(d) < R_smoothing) |
| 120 | + |
| 121 | + # The fill factor is used to perform the SSP1 bilinear subpixel smoothing. |
| 122 | + # We use the (2D) analytic expression that comes when assuming the smoothing |
| 123 | + # kernel is a circle. Note that because the kernel contains some |
| 124 | + # expressions that are sensitive to NaNs, we have to use the "double where" |
| 125 | + # trick to avoid the Nans in the backward trace. This is a common problem |
| 126 | + # with array-based AD tracers, apparently. See here: |
| 127 | + # https://github.com/google/jax/issues/1052#issuecomment-5140833520 |
| 128 | + d_R = d / R_smoothing |
| 129 | + F = jnp.where( |
| 130 | + needs_smoothing, 0.5 - 15 / 16 * d_R + 5 / 8 * d_R**3 - 3 / 16 * d_R**5, 1.0 |
| 131 | + ) |
| 132 | + # F(-d) |
| 133 | + F_minus = jnp.where( |
| 134 | + needs_smoothing, 0.5 + 15 / 16 * d_R - 5 / 8 * d_R**3 + 3 / 16 * d_R**5, 1.0 |
| 135 | + ) |
| 136 | + |
| 137 | + # Determine the upper and lower bounds of materials in the current pixel (before projection). |
| 138 | + rho_filtered_minus = rho_filtered - R_smoothing * rho_filtered_grad_norm_eff * F |
| 139 | + rho_filtered_plus = ( |
| 140 | + rho_filtered + R_smoothing * rho_filtered_grad_norm_eff * F_minus |
| 141 | + ) |
| 142 | + |
| 143 | + # Finally, we project the extents of our range. |
| 144 | + rho_minus_eff_projected = tanh_projection(rho_filtered_minus, beta=beta, eta=eta) |
| 145 | + rho_plus_eff_projected = tanh_projection(rho_filtered_plus, beta=beta, eta=eta) |
| 146 | + |
| 147 | + # Only apply smoothing to interfaces |
| 148 | + rho_projected_smoothed = ( |
| 149 | + 1 - F |
| 150 | + ) * rho_minus_eff_projected + F * rho_plus_eff_projected |
| 151 | + return jnp.where( |
| 152 | + needs_smoothing, |
| 153 | + rho_projected_smoothed, |
| 154 | + rho_projected, |
| 155 | + ) |
0 commit comments