Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

enable support for subpixel smoothing of MaterialGrid #1503

Merged
merged 4 commits into from
Feb 21, 2021

Conversation

oskooi
Copy link
Collaborator

@oskooi oskooi commented Feb 15, 2021

Closes #1500.

After some recent consultation with @smartalecH, it turns out enabling subpixel smoothing for the MaterialGrid is actually much easier than expected. All that is required is to make sure that the parameter fallback is set to true inside geom_epsilon::eff_chi1inv_matrix in order to use the existing adaptive quadrature routine from libctl:

if (fallback) { fallback_chi1inv_row(c, chi1inv_row, v, tol, maxeval); }

Because the routine geom_epsilon::fallback_chi1inv_row invokes geom_epsilon::get_material_pt(material_type &material, const meep::vec &r) to obtain ε at a given point r for which the use case of material as a material grid is already supported, no additional functions need to be added. In fact, setting it up this way means that subpixel smoothing also automatically supports symmetries for the material grid.

This PR simply adds a new do_averaging boolean property to the MaterialGrid object (default is False) which is passed from the Python interface into geom_epsilon::eff_chi1inv_matrix in C/C++. This means that the existing parameters subpixel_maxeval and subpixel_tol for the Simulation constructor which are used to specify the properties of the quadrature scheme can be used as-is.

The convergence test described in #1500 (shown below) verifies that the quadrature scheme for the MaterialGrid is second-order accurate (green line). This requires making sure that the resolution of the MaterialGrid is suffficiently higher than Meep's Yee grid (in this example, the ratio is 100). If the resolution of the MaterialGrid is small relative to the Meep resolution (e.g., 2X), the convergence is closer to first-order accurate.

The next feature to add is the gradient of the subpixel smoothing for back propagation.

relerr_vs_resolution

import numpy as np
import argparse
import meep as mp

parser = argparse.ArgumentParser()
parser.add_argument('-res',
                    type=int,
                    default=20,
                    help='resolution (default: 20 pixels/um)')
parser.add_argument('-geom_type',
                    type=int,
                    choices=[1,2],
                    default=1,
                    help='type of geometry: 1: Cylinder (default), 2: material grid')
args = parser.parse_args()

cell_size = mp.Vector3(1,1,0)

rad = 0.301943

if args.geom_type == 1:
    geometry = [mp.Cylinder(radius=rad,
                            center=mp.Vector3(),
                            height=mp.inf,
                            material=mp.Medium(index=3.5))]
else:
    design_shape = mp.Vector3(1,1,0)
    design_region_resolution = int(100*args.res)
    Nx = int(design_region_resolution*design_shape.x)
    Ny = int(design_region_resolution*design_shape.y)
    x = np.linspace(-0.5*cell_size.x,0.5*cell_size.x,Nx)
    y = np.linspace(-0.5*cell_size.y,0.5*cell_size.y,Ny)
    xv, yv = np.meshgrid(x,y)
    design_params = np.sqrt(np.square(xv) + np.square(yv)) < rad

    matgrid = mp.MaterialGrid(mp.Vector3(Nx,Ny),
                              mp.air,
                              mp.Medium(index=3.5),
                              design_parameters=design_params,
                              grid_type='U_SUM',
                              do_averaging=True)

    geometry = [mp.Block(center=mp.Vector3(),
                         size=mp.Vector3(design_shape.x,design_shape.y,0),
                         material=matgrid)]

fcen = 0.3
df = 0.2*fcen
sources = [mp.Source(mp.GaussianSource(fcen,fwidth=df),
                     component=mp.Hz,
                     center=mp.Vector3(-0.1057,0.2094,0))]

k_point = mp.Vector3(0.3892,0.1597,0)

sim = mp.Simulation(resolution=args.res,
                    cell_size=cell_size,
                    geometry=geometry,
                    sources=sources,
                    k_point=k_point)

h = mp.Harminv(mp.Hz, mp.Vector3(0.3718,-0.2076), fcen, df)
sim.run(mp.after_sources(h),
        until_after_sources=200)

for m in h.modes:
    print("harminv:, {}, {}, {}".format(args.res,m.freq,m.Q))

@oskooi
Copy link
Collaborator Author

oskooi commented Feb 16, 2021

Here is a plot of the relative error vs. resolution for the MaterialGrid with subpixel smoothing at different resolutions relative to the Meep grid. It's only when the MaterialGrid resolution is ~20X the Meep resolution (yellow line) and beyond that the quadratic convergence rate is distinguishable. Note that when the MaterialGrid resolution is 2X the Meep resolution (red line), the convergence rate is roughly linear.

relerr_vs_resolution

In terms of runtime performance, the larger the resolution of the MaterialGrid, obviously the more memory storage that is required. However since the MaterialGrid is comprised of only two materials, the NumPy array comprising the MaterialGrid therefore contains only 2-bit booleans rather than 64-bit floating points. The reduced storage requirements combined with the C implementation of the adaptive quadrature routine (in libctl) means that the actual execution time for set_epsilon is fast. For example, at a Meep resolution of 300 and MaterialGrid resolution of 15000 (i.e., 50X), the NumPy array of 15000 x 15000 bool values (equivalent to ~0.4 GB of storage) executes in ~13 s using two cores of Intel Xeon 4.2 GHz.

@smartalecH
Copy link
Collaborator

However since the MaterialGrid is comprised of only two materials, the NumPy array comprising the MaterialGrid, therefore, contains only 2-bit booleans rather than 64-bit floating points.

Unfortunately, this isn't true. Remember, the material grid stores continuous values from 0 to 1, up to whatever precision you specify. In theory, you could use a binary array... but this not only kills your optimization, but the SWIG code still casts this as a 64-bit float array.

More importantly, the whole point of TO is to leverage continuous optimization methods on a large scale.

That being said, the storage requirements aren't terrible from meep's perspective. Most of the other data arrays are far bigger. Rather, the optimizer needs to be able to handle an array of that size. Normally MMA algorithms (e.g. CCSA in NLOPT) should work just fine. However, if you are using MPI, then this algorithm runs on each process, which means this array is copied to each process. You could of course get around this by only optimizing on one processor and broadcasting the results, but you just need to make sure your bookkeeping is correct.

@stevengj
Copy link
Collaborator

stevengj commented Feb 16, 2021

It occurs to me that we may be able to do better if we implement the projection step as part of the MaterialGrid rather than in Python. The basic reason for this is that, before projection, we have a nice smooth (low-pass-filtered) function, for which bilinear interpolation is a good description.

That is, suppose g∈[0,1]ⁿ is our MaterialGrid data (before projection) and L(g,𝐱) ∈ [0,1] denotes the bilinear interpolation of g at 𝐱. Then, we define ε at each point in space (at "infinite" resolution) by ε(𝐱) = P(ε₁,ε₂,L(g,𝐱)) where P is our projection function onto [ε₁,ε₂]. Our mean ⟨ε⟩ etcetera is then defined by integrals of ε(𝐱), which we can sample as finely in space as we want (e.g. at 30× the resolution, or using specialized quadrature schemes).

(Another way of putting it is that we should interpolate and then project, rather than project on the grid and then interpolate.)

@stevengj
Copy link
Collaborator

In particular, the MaterialGrid object would have two additional parameters β and η (for the tanh projection, taken from Python). Then matgrid_val would apply this projection to the interpolated result (if β≠0 … if β=0 we should just skip the projection).

@oskooi
Copy link
Collaborator Author

oskooi commented Feb 17, 2021

For reference, the hyperbolic tangent function that is to be replicated in C is:

return (npa.tanh(beta*eta) + npa.tanh(beta*(x-eta))) / (npa.tanh(beta*eta) + npa.tanh(beta*(1-eta)))

We will need to use the tanh function from C's math library which is already loaded in meep.hpp.

@stevengj
Copy link
Collaborator

stevengj commented Feb 17, 2021

The point is that this then decouples the MaterialGrid resolution from the Meep resolution. The MaterialGrid effectively defines an discontinuous ε(x) at infinite resolution, which you can then either do subpixel averaging on (to get the Meep ε) or sample at any desired resolution to ship to the foundry (by upsampling & then projecting), so that the Meep calculation is consistent (to the degree allowed by the Meep resolution) with the foundry design.

(Conversely, if you project and then interpolate, then the MaterialGrid defines a continuous ε(x) at infinite resolution, which is not what you want.)

Note also that you would test convergence by keeping the MaterialGrid resolution fixed and increasing the Meep resolution.

@stevengj
Copy link
Collaborator

stevengj commented Feb 17, 2021

The MaterialGrid should have a high enough resolution that the "corners" induced in the interpolated+projected process (at infinite resolution you will have "corners" at the boundaries between MaterialGrid pixels) are shallow enough that they don't affect convergence much.

For a test case, I would probably use a smooth MaterialGrid input, similar to what we plan to use in the adjoint solver with filters. You can either get this by taking your cylinder and filtering it. or just by putting in u = Gaussian "bump" or similar. Use a large β (say 1000) to get a binary structure, and η=0.5.

@oskooi
Copy link
Collaborator Author

oskooi commented Feb 18, 2021

This seems to be working and producing quadratic convergence using a fixed resolution for the MaterialGrid as demonstrated below. The test involves specifying a 1.0 x 1.0 MaterialGrid with a fixed resolution of 50 by applying a Gaussian filter to the initial binarized cylinder design. The projection operator uses β = 1000 and η = 0.5, as suggested.

The filtered design which is then interpolated is shown in the figure inset. Note that the boundaries have been blurred using SciPy's gaussian_filter function.

Two new parameters beta and eta for the projection/thresholding operator (hyperbolic tangent) have been added to the MaterialGrid class object which are then passed into matgrid_val of meepgeom.cpp to apply the projection after the interpolation.

interp_project_convergence

import numpy as np
from scipy.ndimage import gaussian_filter
import argparse
import meep as mp
import matplotlib
import time

parser = argparse.ArgumentParser()
parser.add_argument('-res',
                    type=int,
                    default=20,
                    help='resolution (default: 20 pixels/um)')
parser.add_argument('-geom_type',
                    type=int,
                    choices=[1,2],
                    default=1,
                    help='type of geometry: 1: Cylinder (default), 2: material grid')
args = parser.parse_args()

cell_size = mp.Vector3(1,1,0)

rad = 0.301943

if args.geom_type == 1:
    geometry = [mp.Cylinder(radius=rad,
                            center=mp.Vector3(),
                            height=mp.inf,
                            material=mp.Medium(index=3.5))]
else:
    design_shape = mp.Vector3(1,1,0)
    design_region_resolution = 50
    Nx = int(design_region_resolution*design_shape.x)
    Ny = int(design_region_resolution*design_shape.y)
    x = np.linspace(-0.5*cell_size.x,0.5*cell_size.x,Nx)
    y = np.linspace(-0.5*cell_size.y,0.5*cell_size.y,Ny)
    xv, yv = np.meshgrid(x,y)
    design_params = np.sqrt(np.square(xv) + np.square(yv)) < rad
    filtered_design_params = gaussian_filter(design_params, sigma=3.0, mode='nearest', output=np.double)

    matgrid = mp.MaterialGrid(mp.Vector3(Nx,Ny),
                              mp.air,
                              mp.Medium(index=3.5),
                              design_parameters=filtered_design_params,
                              grid_type='U_SUM',
                              do_averaging=True,
                              beta=1000,
                              eta=0.5)

    geometry = [mp.Block(center=mp.Vector3(),
                         size=mp.Vector3(design_shape.x,design_shape.y,0),
                         material=matgrid)]

fcen = 0.3
df = 0.2*fcen
sources = [mp.Source(mp.GaussianSource(fcen,fwidth=df),
                     component=mp.Hz,
                     center=mp.Vector3(-0.1057,0.2094,0))]

k_point = mp.Vector3(0.3892,0.1597,0)

sim = mp.Simulation(resolution=args.res,
                    cell_size=cell_size,
                    geometry=geometry,
                    sources=sources,
                    k_point=k_point,
                    subpixel_maxeval=100000,
                    subpixel_tol=1e-5)

start = time.time()
sim.init_sim()
print("set_epsilon:, {} s".format(time.time()-start))

h = mp.Harminv(mp.Hz, mp.Vector3(0.3718,-0.2076), fcen, df)
sim.run(mp.after_sources(h),
        until_after_sources=200)

for m in h.modes:
    print("harminv:, {}, {}, {}".format(args.res,m.freq,m.Q))

@smartalecH
Copy link
Collaborator

Looks good! It might be nice to have...

  • a python MaterialGrid helper function that can easily set the beta/eta values for the material grid. Something that we can plug into the OptimizationProblem interface rather easily.
  • a python MaterialGrid helper function that can interpolate and project the material grid onto any size array. Use cases include generating a higher resolution design field for image segmentation and shipping out to a foundry. Something like interpolate_and_project(Nx=None,Ny=None) where Nx and Ny are the desired output sizes of the grid. The default None will use the default resolution.

And of course, we still need to add gradient support.

@ianwilliamson
Copy link
Contributor

This is an interesting approach to getting subpixel smoothing working with MaterialGrid. This change seems to move Meep's MaterialGrid interface in a direction where it is tightly coupled to a very specific three field parameterization.

I'm just wondering if it would be useful to keep a more general interface for MaterialGrid? What's nice about the current interface is that it is very general and the user may use any parameterization that they wish (or any flavor of three field). What do you think?

@stevengj
Copy link
Collaborator

This change seems to move Meep's MaterialGrid interface in a direction where it is tightly coupled to a very specific three field parameterization.

I'm not quite sure which three fields you're referring to?

The proposed change would tightly couple the material grid to a particular projection operation, but even then you could turn this projection off (set β=0) and do the projection separately in any way that you want (albeit without subpixel smoothing).

@smartalecH
Copy link
Collaborator

I'm just wondering if it would be useful to keep a more general interface for MaterialGrid?

If beta=0 (the default) then you get the same behavior as before (i.e. no thresholding). So it's still perfectly generalizable.

Note that you can still parameterize however you want before you pass into the material grid. You can do as many filters, thresholds, or function fits as you have time for.

@stevengj
Copy link
Collaborator

stevengj commented Feb 18, 2021

It also occurs to me that we can now compute the normal vector directly via by averaging the gradient ∇u of the interpolated field before projection, similar to the discussion #1229 (since it's essentially a level-set function), rather than by spherical quadrature. (We could also do the ε averaging in a more clever way too.)

@ianwilliamson
Copy link
Contributor

I'm not quite sure which three fields you're referring to?

I was thinking of different kinds and combinations of projection / filtering operations, along the lines of what @smartalecH mentioned. It initially struck me as less flexible to hardcode one of these operations into the Meep MaterialGrid interface, although as you both point out, this can effectively be disabled to retain the existing behavior.

albeit without subpixel smoothing

Is there an approach that would allow for subpixel smoothing in the MaterialGrid interface without assuming a particular three field representation? For example, maybe I have a very high resolution design that's very close to binarized.

It seems like we just need a way to provide Meep with a continuous (or at least effectively continuous) representation of a design here? Above, my very high resolution binarized design might be one example of an effectively continuous design. The smooth level set variable (after interpolation) might be the continuous representation in the three field case. Is there an approach that could support both of these cases?

@stevengj
Copy link
Collaborator

Is there an approach that would allow for subpixel smoothing in the MaterialGrid interface without assuming a particular three field representation? For example, maybe I have a very high resolution design that's very close to binarized.

Yes. Just use a MaterialGrid with β=0 at a high resolution, which still works with this PR (at a cost in memory).

@stevengj
Copy link
Collaborator

a python MaterialGrid helper function that can easily set the beta/eta values for the material grid.

Why do you need a helper function here, instead of just directly accessing the fields, i.e. mgrid.beta = something?

src/meepgeom.cpp Outdated Show resolved Hide resolved
src/meepgeom.cpp Outdated Show resolved Hide resolved
python/geom.py Outdated Show resolved Hide resolved
@smartalecH
Copy link
Collaborator

For example, maybe I have a very high resolution design that's very close to binarized.

Say, for example you already have a design that works pretty well, but maybe you just want to eek out a bit of performance with another round of optimization.

As @stevengj mentioned, you could still use sub-pixel averaging on this, provided the original design has a really high resolution and you turn off the threshold.

Or you could do a mini topology optimization problem, where you want to find the new set of design parameters that, after passing through a filter and projection, correspond to your initial starting guess. Because of the nonlinear projection, it's not going to be a convex problem, but should be pretty straightfoward to solve (I've done this quite a few times with meep's adjoint framework). In fact, you can use the existing autograd libraries to efficiently compute the gradients.

Then, with this new set of design parameters, you can continue to optimize with the projection step and take advantage of the subpixel averaging without having to hypersample your image (lots of memory) because you are passing it through a filter first.

This methodology can be applied to other use cases too.

@ianwilliamson
Copy link
Contributor

Or you could do a mini topology optimization problem, where you want to find the new set of design parameters that, after passing through a filter and projection, correspond to your initial starting guess. Because of the nonlinear projection, it's not going to be a convex problem, but should be pretty straightfoward to solve (I've done this quite a few times with meep's adjoint framework). In fact, you can use the existing autograd libraries to efficiently compute the gradients.

Then, with this new set of design parameters, you can continue to optimize with the projection step and take advantage of the subpixel averaging without having to hypersample your image (lots of memory) because you are passing it through a filter first.

Yeah, performing re-optimization is certainly an interesting use case there.

As I mentioned above, I think what you're describing is one way of creating an effectively continuous representation in the three field parameterization: the bilinear interpolation of a relatively coarse filtered density array will give Meep a smooth level set-like distribution that can be thresholded. That's all good, but what strikes me as odd is the choice of hardcoding this into the Meep interface, when you could instead have a Meep adjoint module that is much more general.

Not all users may be optimizing with a three field parameterization. A user could have some other function for generating their geometry (either that they want to optimize or that they just want to simulate with the benefit of subpixel smoothing). Being required to pass this geometry to Meep as a very high resolution numpy array seems less flexible and may not be the most convenient. For example, perhaps the geometry isn't coming from an array in the first place.

You could still support the three field use case you describe above but with an interface that is more extensible to enable other approaches. I think what I am imagining here actually looks very similar to the material function interface that already exists? You give Meep a way of querying density values at arbitrary (finely) sampled points. Maybe you need to assume some well-behavedness or smoothness of the function, but that seems fair. You would also need an interface to pass the sensitivity / gradient at those points back out, but you basically already have this. You could then build the three field-specific scheme with the bilinear interpolation on top of this API, but still allow flexibility for other use cases.

@stevengj
Copy link
Collaborator

stevengj commented Feb 20, 2021

You give Meep a way of querying density values at arbitrary (finely) sampled points. Maybe you need to assume some well-behavedness or smoothness of the function, but that seems fair

You already have a way of querying density values at arbitrarily finely sampled points with the MaterialGrid (which currently offers linear interpolation from the grid). The basic issue here is precisely that this is a poor representation of discontinuous functions unless you have a very fine grid resolution, or unless the MaterialGrid is treated as level-set function (i.e. an interface at u(x)=η).

Incorporating the (optional) projection into the MaterialGrid precisely corresponds to allowing you to treat it as a level set (with optional smoothing β and offset η), which is a very general way to describe discontinuous materials that still allows efficient subpixel smoothing and other operations.

It is not specific to a "three-field" parameterization that you keep referring to.

I think what I am imagining here actually looks very similar to the material function interface that already exists?

We already have this feature, and you can already do subpixel smoothing with it. The basic problem is that it is extremely slow because you have to call back to Python many times per pixel.

If there are other flexible interfaces we could implement to specify the geometry, we can certainly consider them. But I think level sets are a useful general feature to have.

@ianwilliamson
Copy link
Contributor

ianwilliamson commented Feb 20, 2021

We already have this feature, and you can already do subpixel smoothing with it.

Thanks for the clarification. I incorrectly thought that subpixel smoothing was not supported in that interface. I suppose there's also the issue of getting gradients if one wanted to do optimization using that but, more importantly, I can imagine that it is slow.

Just a thought... but could an alternative interface be one where Meep provides the coordinates of the points where it wants to perform the evaluations? This might allow one to take advantage of vectorization and other speed ups on the Python side, depending on how the function is computed.

@stevengj
Copy link
Collaborator

could an alternative interface be one where Meep provides the coordinates of the points where it wants to perform the evaluations?

It's pretty hard to do subpixel smoothing that way, because the subpixel quadrature points required would be hard to compute in advance (e.g. with vectorized code) without massive memory usage.

@stevengj stevengj merged commit 7eaf95d into NanoComp:master Feb 21, 2021
@oskooi oskooi deleted the matgrid_subpix branch February 21, 2021 04:00
bencbartlett pushed a commit to bencbartlett/meep that referenced this pull request Sep 9, 2021
* enable support for subpixel smoothing of MaterialGrid

* apply hyperbolic tangent projection/thresholding after bilinear interpolation

* fixes

* minor fix to if statement
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

enabling support for subpixel smoothing of MaterialGrid
4 participants