This notebook introduces surfaces parameterized by heightmaps.
# !pip install git+https://github.com/tum-pbs/PhiFlow@3.0
from phi.flow import *
Heightmaps can be used in 2D or 3D. They encode a grid surface made up of lines or planes, respectively. The grid vertices are equally spaced and the displacement (height) of each point is specified via a 1D or 2D tensor.
The following code creates a heightmap in 2D from a list of height values.
height = wrap([.1, .02, 0, 0, 1, .95, .8, .5, 0], spatial('x'))
bounds = Box(x=2, y=1)
heightmap = geom.Heightmap(height, bounds, max_dist=.1)
plot(heightmap)
The heightmap surface separates the inside from the outside.
Whether the inside is below or above the height values can be set in the Heightmap
constructor.
is_inside = CenteredGrid(lambda loc: heightmap.lies_inside(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(is_inside)
/opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_matplotlib/_matplotlib_plots.py:167: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect. plt.tight_layout() # because subplot titles can be added after figure creation
While the inside check is always exact, the distance from the surface is an approximation.
For small distances, the face directly underneath a point will usually be closest but for farther points this is not always true.
To account for this, each face links to one other face that will be queried for larger distances.
Finding these secondary faces is performed during heightmap construction where the parameter max_dist
influences which other faces are the most important to link.
Above, we set max_dist=0.1
. In the following cell, we visualize the distance from the surface.
distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(distance, heightmap, overlay='args')
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[4], line 1 ----> 1 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2)) 2 plot(distance, heightmap, overlay='args') File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_grid.py:75, in CenteredGrid(values, boundary, bounds, resolution, extrapolation, convert, **resolution_) 73 values = sample(values, elements) 74 elif callable(values): ---> 75 values = sample_function(values, elements, 'center', extrapolation) 76 else: 77 if isinstance(values, (tuple, list)) and len(values) == resolution.rank: File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:869, in sample_function(f, elements, at, extrapolation) 867 values = math.map_s2b(f)(*pos.vector) 868 else: --> 869 values = math.map_s2b(f)(pos) 870 assert isinstance(values, math.Tensor), f"values function must return a Tensor but returned {type(values)}" 871 return values File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_functional.py:1200, in map_types.<locals>.retyped_f(*args, **kwargs) 1198 retyped_kwarg, input_types = forward_retype(v, input_types) 1199 retyped_kwargs[k] = retyped_kwarg -> 1200 output = f(*retyped_args, **retyped_kwargs) 1201 restored_output = reverse_retype(output, input_types) 1202 return restored_output Cell In[4], line 1, in <lambda>(loc) ----> 1 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2)) 2 plot(distance, heightmap, overlay='args') File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:218, in Heightmap.approximate_signed_distance(self, location) 217 def approximate_signed_distance(self, location: Tensor) -> Tensor: --> 218 return self.approximate_closest_surface(location)[0] File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:158, in Heightmap.approximate_closest_surface(self, location) 156 # --- use closest face from considered --- 157 delta = math.where(projects_onto_face, proj_delta, delta_highest) --> 158 return math.at_min((distances, delta, normals, offsets, face_idx), key=abs(distances), dim=batch('consider') & instance(self).as_batch()) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1941, in at_min(value, key, dim) 1939 if not shape(key).only(dim): 1940 return value -> 1941 idx = argmin(key, dim) 1942 return slice_(value, idx) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2034, in argmin(x, dim, index_dim) 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes) 2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)]) -> 2034 return broadcast_op(uniform_argmin, [x], broadcast) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1149, in broadcast_op(operation, tensors, iter_dims, no_return) 1147 iter_dims = broadcast_dims(*tensors) if iter_dims is None else iter_dims 1148 if len(iter_dims) == 0: -> 1149 return operation(*tensors) 1150 else: 1151 if isinstance(iter_dims, SHAPE_TYPES): File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2032, in argmin.<locals>.uniform_argmin(x) 2030 v_native = x._reshaped_native([keep - broadcast, dims]) 2031 idx_native = x.backend.argmin(v_native, 1, keepdims=True) -> 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes) 2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)]) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/backend/_numpy_backend.py:319, in NumPyBackend.unravel_index(self, flat_index, shape) 318 def unravel_index(self, flat_index, shape): --> 319 return np.stack(np.unravel_index(flat_index, shape), -1) TypeError: 'NoneType' object cannot be interpreted as an integer
With max_dist=0.1
, the values further than 0.1 away from the cliff get inaccurate distances.
Let's increase `max_dist´ to see the difference.
heightmap = geom.Heightmap(height, bounds, max_dist=1)
distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2))
plot(distance, heightmap, overlay='args')
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[5], line 2 1 heightmap = geom.Heightmap(height, bounds, max_dist=1) ----> 2 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2)) 3 plot(distance, heightmap, overlay='args') File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_grid.py:75, in CenteredGrid(values, boundary, bounds, resolution, extrapolation, convert, **resolution_) 73 values = sample(values, elements) 74 elif callable(values): ---> 75 values = sample_function(values, elements, 'center', extrapolation) 76 else: 77 if isinstance(values, (tuple, list)) and len(values) == resolution.rank: File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_geom.py:869, in sample_function(f, elements, at, extrapolation) 867 values = math.map_s2b(f)(*pos.vector) 868 else: --> 869 values = math.map_s2b(f)(pos) 870 assert isinstance(values, math.Tensor), f"values function must return a Tensor but returned {type(values)}" 871 return values File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_functional.py:1200, in map_types.<locals>.retyped_f(*args, **kwargs) 1198 retyped_kwarg, input_types = forward_retype(v, input_types) 1199 retyped_kwargs[k] = retyped_kwarg -> 1200 output = f(*retyped_args, **retyped_kwargs) 1201 restored_output = reverse_retype(output, input_types) 1202 return restored_output Cell In[5], line 2, in <lambda>(loc) 1 heightmap = geom.Heightmap(height, bounds, max_dist=1) ----> 2 distance = CenteredGrid(lambda loc: heightmap.approximate_signed_distance(loc), x=100, y=100, bounds=Box(x=2, y=2)) 3 plot(distance, heightmap, overlay='args') File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:218, in Heightmap.approximate_signed_distance(self, location) 217 def approximate_signed_distance(self, location: Tensor) -> Tensor: --> 218 return self.approximate_closest_surface(location)[0] File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/geom/_heightmap.py:158, in Heightmap.approximate_closest_surface(self, location) 156 # --- use closest face from considered --- 157 delta = math.where(projects_onto_face, proj_delta, delta_highest) --> 158 return math.at_min((distances, delta, normals, offsets, face_idx), key=abs(distances), dim=batch('consider') & instance(self).as_batch()) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1941, in at_min(value, key, dim) 1939 if not shape(key).only(dim): 1940 return value -> 1941 idx = argmin(key, dim) 1942 return slice_(value, idx) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2034, in argmin(x, dim, index_dim) 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes) 2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)]) -> 2034 return broadcast_op(uniform_argmin, [x], broadcast) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:1149, in broadcast_op(operation, tensors, iter_dims, no_return) 1147 iter_dims = broadcast_dims(*tensors) if iter_dims is None else iter_dims 1148 if len(iter_dims) == 0: -> 1149 return operation(*tensors) 1150 else: 1151 if isinstance(iter_dims, SHAPE_TYPES): File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_ops.py:2032, in argmin.<locals>.uniform_argmin(x) 2030 v_native = x._reshaped_native([keep - broadcast, dims]) 2031 idx_native = x.backend.argmin(v_native, 1, keepdims=True) -> 2032 multi_idx_native = choose_backend(idx_native).unravel_index(idx_native[:, 0], dims.sizes) 2033 return reshaped_tensor(multi_idx_native, [keep - broadcast, index_dim.with_size(dims.name_list)]) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/backend/_numpy_backend.py:319, in NumPyBackend.unravel_index(self, flat_index, shape) 318 def unravel_index(self, flat_index, shape): --> 319 return np.stack(np.unravel_index(flat_index, shape), -1) TypeError: 'NoneType' object cannot be interpreted as an integer
Now larger distances are covered as well. This runs with the same number of computations but trades off accuracy closer to the surface.
We can additionally query the direction to the closest surface point as well as the corresponding normal vector.
Let's plot these vectors for random points inside (orange) and outside (red) of the heightmap.
points = Box(x=3, y=(-1, 1)).sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
plot({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
/opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_tensors.py:1377: RuntimeWarning: invalid value encountered in power result = op(n1, n2)
The grid orientation is determined by which spatial dimension is not part of the grid. E.g. to create a vertical heightmap, the displacement values should be listed along the y direction.
height = wrap([.1, .02, 0, 0, 1, .95, .8, .5, 0], spatial('y'))
bounds = Box(x=1, y=2)
plot(geom.Heightmap(height, bounds, max_dist=.1))
Adding a third dimension z
yields a 2D heightmap in 3D space.
Here, we create height values from a function, but the rest stays the same.
bounds = Box(x=2, y=2, z=1)
height = CenteredGrid(lambda pos: math.exp(-math.vec_squared(pos-1) * 3), 0, bounds['x,y'], x=10, y=10).values
heightmap = geom.Heightmap(height, bounds, max_dist=.1)
show(heightmap)
/tmp/ipykernel_2568/959595594.py:2: DeprecationWarning: phiml.math.vec_squared is deprecated in favor of phiml.math.squared_norm height = CenteredGrid(lambda pos: math.exp(-math.vec_squared(pos-1) * 3), 0, bounds['x,y'], x=10, y=10).values
Again, we can sample directions and distances from the surface.
points = bounds.sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
--------------------------------------------------------------------------- ValueError Traceback (most recent call last) Cell In[9], line 3 1 points = bounds.sample_uniform(instance(points=200)) 2 sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points) ----> 3 show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))], 4 'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1]) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:58, in show(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields) 56 kwargs = locals() 57 del kwargs['fields'] ---> 58 fig = plot(*fields, **kwargs) 59 plots = get_plots_by_figure(fig) 60 if isinstance(fig, Tensor): File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:322, in plot(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields) 320 for i, f in enumerate(fields): 321 idx = indices[pos][i] --> 322 plots.plot(f, figure, axes[pos], subplots[pos], min_val, max_val, show_color_bar, color[pos][i], alpha[idx], err[idx]) 323 plots.finalize(figure) 324 LAST_FIGURE[0] = figure File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis_base.py:382, in PlottingLibrary.plot(self, data, figure, subplot, space, *args, **kwargs) 380 for recipe in self.recipes: 381 if recipe.can_plot(data, space): --> 382 recipe.plot(data, figure, subplot, space, *args, **kwargs) 383 return 384 raise NotImplementedError(f"No {self.name} recipe found for {data}. Recipes: {self.recipes}") File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_dash/_plotly_plots.py:318, in VectorCloud3D.plot(self, data, figure, subplot, space, min_val, max_val, show_color_bar, color, alpha, err) 316 colorscale = 'Blues' 317 else: --> 318 hex_color = plotly_color(color) 319 colorscale = [[0, hex_color], [1, hex_color]] 320 if data.is_staggered: File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_dash/_plotly_plots.py:710, in plotly_color(col) 708 col = col.numpy() 709 if isinstance(col, np.ndarray): --> 710 col = col.item() 711 if isinstance(col, int): 712 return DEFAULT_PLOTLY_COLORS[col % len(DEFAULT_PLOTLY_COLORS)] ValueError: can only convert an array of size 1 to a Python scalar
Like all Geometry
objects, heightmaps can be merged using the union
function.
If we only pass Heightmap
objects with the same grid resolution, ΦFlow will automatically vectorize computations so that the latency does not increase.
We can use this to create complex obstacles with detailed surfaces on all sides or to create cavities.
height = (1 - math.linspace(-1, 1, spatial(x=10)) ** 2) ** .5
upper_heightmap = geom.Heightmap(height, Box(x=(-1, 1), y=None), max_dist=.1, fill_below=False, extrapolation=0)
lower_heightmap = geom.Heightmap(-height, Box(x=(-1, 1), y=None), max_dist=.1, fill_below=True, extrapolation=0)
heightmap = union(lower_heightmap, upper_heightmap)
points = Box(x=(-2, 2), y=(-2, 2)).sample_uniform(instance(points=200))
sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points)
show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))],
'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1])
--------------------------------------------------------------------------- IncompatibleShapes Traceback (most recent call last) Cell In[10], line 8 6 points = Box(x=(-2, 2), y=(-2, 2)).sample_uniform(instance(points=200)) 7 sgn_dist, delta, normal, _, face_index = heightmap.approximate_closest_surface(points) ----> 8 show({'closest': [heightmap, PointCloud(points, .1 * math.vec_normalize(delta))], 9 'normal': [heightmap, PointCloud(points, .2 * normal)]}, overlay='list', color=[0, heightmap.lies_inside(points)*2+1]) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:58, in show(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields) 56 kwargs = locals() 57 del kwargs['fields'] ---> 58 fig = plot(*fields, **kwargs) 59 plots = get_plots_by_figure(fig) 60 if isinstance(fig, Tensor): File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:252, in plot(lib, row_dims, col_dims, animate, overlay, title, size, same_scale, log_dims, show_color_bar, color, alpha, err, frame_time, repeat, plt_params, max_subfigures, *fields) 250 ncols = uniform_bound(col_dims).volume 251 assert nrows * ncols <= max_subfigures, f"Too many subfigures ({nrows * ncols}) for max_subfigures={max_subfigures}. If you want to plot this many subfigures, increase the limit." --> 252 positioning, indices = layout_sub_figures(data, row_dims, col_dims, animate, overlay, 0, 0) 253 # --- Process arguments --- 254 plots = default_plots(positioning) if lib is None else get_plots(lib) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:375, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index) 373 offset += shape(e).only(row_dims).volume 374 elif dim0.only(col_dims): --> 375 layout_sub_figures(e, row_dims, col_dims, animate, overlay, offset_row, offset_col + offset, positioning, indices, index) 376 offset += shape(e).only(col_dims).volume 377 else: File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:375, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index) 373 offset += shape(e).only(row_dims).volume 374 elif dim0.only(col_dims): --> 375 layout_sub_figures(e, row_dims, col_dims, animate, overlay, offset_row, offset_col + offset, positioning, indices, index) 376 offset += shape(e).only(col_dims).volume 377 else: File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:362, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index) 359 if dim0.only(overlay): 360 for overlay_index in dim0.only(overlay).meshgrid(names=True): # overlay these fields 361 # ToDo expand constants along rows/cols --> 362 layout_sub_figures(data[overlay_index], row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, {**base_index, **overlay_index}) 363 return positioning, indices 364 elif dim0.only(animate): File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/vis/_vis.py:382, in layout_sub_figures(data, row_dims, col_dims, animate, overlay, offset_row, offset_col, positioning, indices, base_index) 380 # --- data must be a plottable object --- 381 data = to_field(data) --> 382 overlay = data.shape.only(overlay) 383 animate = data.shape.only(animate).without(overlay) 384 row_shape = data.shape.only(row_dims).without(animate).without(overlay) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phi/field/_field.py:211, in Field.shape(self) 209 return batch(self.geometry) & self.resolution & non_dual(self.values).without(self.resolution) & self.geometry.shape['vector'] 210 set_shape = self.geometry.sets[self.sampled_at] --> 211 return batch(self.geometry) & (channel(self.geometry) - 'vector') & set_shape & self.values.shape File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:1901, in MixedShape.__and__(self, other) 1899 dims = {**self.dims, **merged.dims} 1900 return replace(self, dims=dims, **{other.dim_type: merged}) -> 1901 return merge_shapes(self, other) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:2549, in merge_shapes(allow_varying_sizes, *objs) 2547 b = pure_merge(*[s.batch for s in shapes], allow_varying_sizes=allow_varying_sizes) 2548 d = pure_merge(*[s.dual for s in shapes], allow_varying_sizes=allow_varying_sizes) -> 2549 i = pure_merge(*[s.instance for s in shapes], allow_varying_sizes=allow_varying_sizes) 2550 s = pure_merge(*[s.spatial for s in shapes], allow_varying_sizes=allow_varying_sizes) 2551 c = pure_merge(*[s.channel for s in shapes], allow_varying_sizes=allow_varying_sizes) File /opt/hostedtoolcache/Python/3.12.9/x64/lib/python3.12/site-packages/phiml/math/_shape.py:2584, in pure_merge(allow_varying_sizes, *shapes) 2582 else: 2583 if not sizes_match: -> 2584 raise IncompatibleShapes(f"Cannot merge shapes {shapes} because dimension '{dim.name}' exists with different sizes.", *shapes) 2585 names1 = prev_dim.slice_names 2586 names2 = dim.slice_names IncompatibleShapes: Cannot merge shapes ((unionⁱ=2), (unionⁱ=1)) because dimension 'union' exists with different sizes.
Here, we set extrapolation=0
to extend the heightmap beyond its bounds
.
Consequently, points with x < -1 or x > 1 count as inside the mesh and are colored red correspondingly.