Skip to content

Commit c5c43ad

Browse files
authored
Merge pull request #1 from NanoComp/SSP_original
Set up Python package, example optimization, and SSP first-order tests
2 parents dee23f2 + ad5aa93 commit c5c43ad

7 files changed

Lines changed: 854 additions & 0 deletions

File tree

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# macOS
2+
.DS_Store
3+
4+
# Python bytecode and caches
5+
__pycache__/
6+
*.py[cod]
7+
8+
# Packaging/build artifacts
9+
*.egg-info/
10+
build/
11+
dist/
12+
13+
# Test/tool caches
14+
.pytest_cache/
15+
.mypy_cache/
16+
17+
# Virtual environments
18+
.venv/
19+
venv/
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
"""
2+
This is a simple example that demonstrates how to use the original "SSP1" algorithm
3+
for subpixel-smoothed projection, combined with bilinear interpolation and conic smoothing.
4+
We set up a simplistic gradient-based optimization problem that attempts
5+
to drive the mean of the output to zero. We set β=∞ and show the gradient is nonzero.
6+
"""
7+
8+
import time
9+
10+
import nlopt
11+
import numpy as np
12+
from jax import grad, jit, value_and_grad
13+
from jax import numpy as jnp
14+
from matplotlib import pyplot as plt
15+
16+
from ssp import conic_filter, get_conic_radius_from_eta_e, ssp1_bilinear
17+
18+
19+
def figure_of_merit(x: jnp.ndarray) -> float:
20+
"""A simple, convex reduction mean as the FOM.
21+
22+
FOM = 1/(n*m)ΣΣ|x|^2
23+
"""
24+
return jnp.mean((jnp.abs(x) ** 2).flatten())
25+
26+
27+
def full_system(x: jnp.ndarray, beta, eta_i, resolution) -> float:
28+
"""Include the projection and FOM"""
29+
return figure_of_merit(ssp1_bilinear(x, beta, eta_i, resolution))
30+
31+
32+
def main():
33+
"""Run the example and optimization."""
34+
35+
# --------------------------------------------- #
36+
# Visualize the SSP transformation
37+
# --------------------------------------------- #
38+
39+
# First set up a random initial condition. We'll use dimensionless units for everything.
40+
lx = 2.0
41+
ly = 2.0
42+
resolution = 50
43+
eta_i = 0.5
44+
eta_e = 0.75
45+
lengthscale = 0.25
46+
filter_radius = get_conic_radius_from_eta_e(lengthscale, eta_e)
47+
nx = int(np.round(lx * resolution) + 1)
48+
ny = int(np.round(ly * resolution) + 1)
49+
50+
np.random.seed(42)
51+
rho = np.random.rand(nx, ny)
52+
beta = np.inf
53+
54+
rho_filtered = conic_filter(rho, filter_radius, lx, ly, resolution)
55+
rho_projected = ssp1_bilinear(rho_filtered, beta, eta_i, resolution)
56+
57+
plt.figure(figsize=(6, 3))
58+
plt.subplot(1, 2, 1)
59+
plt.imshow(rho_filtered, vmin=0, vmax=1, cmap="binary")
60+
plt.title("Input")
61+
plt.colorbar()
62+
plt.subplot(1, 2, 2)
63+
plt.imshow(rho_projected, vmin=0, vmax=1, cmap="binary")
64+
plt.colorbar()
65+
plt.title("SSP Output")
66+
plt.tight_layout()
67+
plt.savefig("projection.png")
68+
69+
# --------------------------------------------- #
70+
# Visualize the SSP gradient for β=∞
71+
# --------------------------------------------- #
72+
73+
d_fom = grad(full_system)
74+
df_drho = d_fom(rho_filtered, beta, eta_i, resolution)
75+
max_val = jnp.max(df_drho)
76+
min_val = jnp.min(df_drho)
77+
vmax_vmin = max([abs(max_val), abs(min_val)])
78+
79+
plt.figure(figsize=(6, 3))
80+
plt.subplot(1, 2, 1)
81+
plt.imshow(rho_projected, vmin=0, vmax=1, cmap="binary")
82+
plt.title("SSP Output")
83+
plt.colorbar()
84+
plt.subplot(1, 2, 2)
85+
plt.imshow(df_drho, vmin=-vmax_vmin, vmax=vmax_vmin, cmap="RdBu")
86+
plt.colorbar()
87+
plt.title("Gradient")
88+
plt.tight_layout()
89+
plt.savefig("gradient.png")
90+
91+
92+
# --------------------------------------------- #
93+
# Shape optimization via nlopt
94+
# --------------------------------------------- #
95+
96+
def optimization_objective(rho_flat: jnp.ndarray) -> jnp.ndarray:
97+
rho_design = rho_flat.reshape((nx, ny))
98+
rho_design_filtered = conic_filter(rho_design, filter_radius, lx, ly, resolution)
99+
rho_design_projected = ssp1_bilinear(
100+
rho_design_filtered, beta, eta_i, resolution
101+
)
102+
return figure_of_merit(rho_design_projected)
103+
104+
objective_and_grad = jit(value_and_grad(optimization_objective))
105+
106+
iteration_history = []
107+
time_history = []
108+
fom_history = []
109+
start_time = time.perf_counter()
110+
111+
def nlopt_objective(x: np.ndarray, grad_out: np.ndarray) -> float:
112+
fom_value, gradient = objective_and_grad(jnp.asarray(x))
113+
114+
if grad_out.size > 0:
115+
grad_out[:] = np.asarray(gradient, dtype=float)
116+
117+
iteration = len(fom_history) + 1
118+
elapsed = time.perf_counter() - start_time
119+
fom_scalar = float(fom_value)
120+
121+
iteration_history.append(iteration)
122+
time_history.append(elapsed)
123+
fom_history.append(fom_scalar)
124+
125+
print(f"iter={iteration:03d} time={elapsed:8.3f}s FOM={fom_scalar:.6e}")
126+
return fom_scalar
127+
128+
opt = nlopt.opt(nlopt.LD_CCSAQ, nx * ny)
129+
opt.set_lower_bounds(np.zeros(nx * ny))
130+
opt.set_upper_bounds(np.ones(nx * ny))
131+
opt.set_min_objective(nlopt_objective)
132+
opt.set_maxeval(25)
133+
134+
x_opt = opt.optimize(rho.ravel())
135+
final_fom = opt.last_optimum_value()
136+
137+
rho_opt = x_opt.reshape((nx, ny))
138+
rho_opt_filtered = conic_filter(rho_opt, filter_radius, lx, ly, resolution)
139+
rho_opt_projected = np.asarray(
140+
ssp1_bilinear(rho_opt_filtered, beta, eta_i, resolution)
141+
)
142+
143+
print("\nOptimization complete")
144+
print(f"status={opt.last_optimize_result()} final_fom={final_fom:.6e}")
145+
146+
print("\nIteration log (time in seconds):")
147+
for iteration, elapsed, fom in zip(iteration_history, time_history, fom_history):
148+
print(f"iter={iteration:03d} time={elapsed:8.3f}s FOM={fom:.6e}")
149+
150+
plt.figure(figsize=(6, 3))
151+
plt.subplot(1, 2, 1)
152+
plt.semilogy(iteration_history, fom_history, marker="o", linewidth=1.5)
153+
plt.xlabel("Iteration")
154+
plt.ylabel("FOM")
155+
plt.title("FOM vs Iteration")
156+
157+
plt.subplot(1, 2, 2)
158+
im = plt.imshow(rho_opt_projected, vmin=0, vmax=1, cmap="binary")
159+
plt.title("Final SSP Projected Design")
160+
plt.colorbar(im)
161+
162+
plt.tight_layout()
163+
plt.savefig("optimization.png")
164+
165+
166+
if __name__ == "__main__":
167+
main()

pyproject.toml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
[build-system]
2+
requires = ["setuptools>=68", "wheel"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "ssp"
7+
version = "0.1.0"
8+
description = "Smoothed subpixel projection (SSP) for topology optimization"
9+
readme = "README.md"
10+
requires-python = ">=3.10"
11+
license = { file = "LICENSE" }
12+
authors = [
13+
{ name = "SSP Contributors" }
14+
]
15+
dependencies = [
16+
"numpy>=1.24",
17+
"jax>=0.4",
18+
]
19+
20+
[project.optional-dependencies]
21+
examples = [
22+
"matplotlib>=3.8",
23+
"nlopt>=2.8",
24+
]
25+
26+
[tool.setuptools]
27+
include-package-data = true
28+
29+
[tool.setuptools.packages.find]
30+
where = ["src/python"]

src/python/ssp/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
"""Public Python API for smoothed subpixel projection (SSP)."""
2+
3+
from .core import ssp1_bilinear
4+
from .utils import conic_filter, get_conic_radius_from_eta_e, tanh_projection
5+
6+
__all__ = [
7+
"ssp1_bilinear",
8+
"conic_filter",
9+
"get_conic_radius_from_eta_e",
10+
"tanh_projection",
11+
]

src/python/ssp/core.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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

Comments
 (0)