parallelize rendering

This commit is contained in:
Brent Schroeter 2025-05-17 12:31:24 -07:00
parent 316cbe065a
commit 2337591f6f
2 changed files with 263 additions and 110 deletions

View file

@ -1,5 +1,7 @@
geopandas==1.0.1
gdal[numpy]==3.6.2
numpy==2.2.5
numpy-stl==3.2.0
pyrosm==0.6.2
shapely==2.1.0
solidpython2==2.1.1

View file

@ -1,13 +1,20 @@
import argparse
import subprocess
import sys
from math import asin, cos, pi, sin, sqrt
from dataclasses import dataclass
from datetime import datetime
from math import asin, ceil, cos, pi, sin, sqrt
from multiprocessing import cpu_count, Pool
from typing import Tuple
import numpy as np
import solid2
import stl
from osgeo import gdal
from pyrosm import OSM
from shapely import affinity, Polygon, union_all
from shapely import affinity, MultiPolygon, Polygon, union_all
def haversine_dist(lat1, lng1, lat2, lng2):
@ -25,6 +32,47 @@ def haversine_dist(lat1, lng1, lat2, lng2):
return 2 * EARTH_RADIUS * asin(sqrt(hav_theta))
def get_elev_grid(elev_map, map_bounds, resolution, scale_factor):
"""
Generate a 2D array of elevation values (in model units), smoothed and
"blocked" to avoid jagged and/or superfluous edges.
"""
elev_grid = np.zeros((resolution, resolution))
for i in range(resolution):
for j in range(resolution):
elev_grid[i, j] = elev_map.get_elevation(
map_bounds[1] + (map_bounds[3] - map_bounds[1]) / resolution * j,
map_bounds[0] + (map_bounds[2] - map_bounds[0]) / resolution * i,
)
elev_grid = (elev_grid - elev_grid.min()) * scale_factor
# Convolve a uniform kernel over matrix to smooth values
KERNEL_SIZE = 5
kernel_offset = (KERNEL_SIZE - 1) / 2
assert kernel_offset % 1 == 0
kernel_offset = int(kernel_offset)
smoothed = np.zeros((resolution, resolution))
for i in range(resolution):
for j in range(resolution):
# Clamping to the array bounds makes the edges of the model less
# smooth, but that's acceptable for these purposes
windowed = elev_grid[
max(i - kernel_offset, 0):min(i + kernel_offset, elev_grid.shape[0]),
max(j - kernel_offset, 0):min(j + kernel_offset, elev_grid.shape[1]),
]
smoothed[i, j] = windowed.mean()
BLOCK_TOLERANCE = 0.1
for block_size in range(2, resolution + 1):
for i in range(resolution - block_size + 1):
for j in range(resolution - block_size + 1):
block = smoothed[i:i+block_size, j:j+block_size].flatten()
if block.max() - block.min() < BLOCK_TOLERANCE:
smoothed[i:i+block_size, j:j+block_size] = block.mean()
return smoothed
def to_scad_polygon(shapely_polygon):
"""
Generate a solid2 SCAD polygon object from a shapely Polygon.
@ -41,7 +89,7 @@ def get_building_height(building):
"""
Infer the height, in meters, of a building from OSM building data.
"""
DEFAULT_METERS_PER_LEVEL = 5
DEFAULT_METERS_PER_LEVEL = 4
DEFAULT_LEVELS_PER_BUILDING = 3
if building["height"] is not None:
return float(building["height"])
@ -69,6 +117,138 @@ class ElevationMap:
raise IndexError("coordinates are outside of elevation map area")
def render_stl(model, file_path):
"""
Like solid2.render_to_stl_file(), but with improved argument interpolation
and stdout/stderr piping.
"""
solid2.scad_render_to_file(model, f"{file_path}.scad")
subprocess.check_call(
["openscad", "-o", file_path, f"{file_path}.scad"],
stdout=sys.stdout,
stderr=sys.stderr,
)
@dataclass
class Renderer:
map_bounds: Tuple[float, float, float, float]
chunk_bounds: Tuple[float, float, float, float]
scale_factor: float
osm_path: str
elev_grid: np.array
baseplate_h: float
output: str
def render_chunk(self):
chunk_bounds = self.chunk_bounds
map_bounds = self.map_bounds
scale_factor = self.scale_factor
elev_grid = self.elev_grid
baseplate_h = self.baseplate_h
map_width = map_bounds[2] - map_bounds[0]
map_height = map_bounds[3] - map_bounds[1]
elev_res = elev_grid.shape[0]
assert elev_res == elev_grid.shape[1]
osm = OSM(self.osm_path)
solid2.set_global_fn(48)
drive_net = osm.get_network(network_type="driving+service")
buildings = osm.get_buildings()
chunk_rect = Polygon([
(0, 0),
(0, chunk_bounds[3]),
(chunk_bounds[2], chunk_bounds[3]),
(chunk_bounds[2], 0),
])
def convert_shape_from_world_coords(shape):
# Move the coordinate system so that the origin is at the bottom left
# of the map rectangle, then scale the shape in x and y directions
return affinity.scale(
affinity.translate(shape, -map_bounds[0], -map_bounds[1]),
chunk_bounds[2] / map_width,
chunk_bounds[3] / map_height,
origin=(0, 0),
)
model = solid2.cube([chunk_bounds[2] - chunk_bounds[0], chunk_bounds[3] - chunk_bounds[1], baseplate_h])
ROAD_HEIGHT = 0.25
# Render roads on slopes by over-extruding them and then calculating their
# intersection with a slightly offset elevation grid
drive_mask = None
for i in range(elev_res):
for j in range(elev_res):
if elev_grid[i, j] > 0:
model += solid2.cube([
chunk_bounds[2] / elev_res,
chunk_bounds[3] / elev_res,
elev_grid[i, j] + baseplate_h,
]).translate(
chunk_bounds[2] / elev_res * i,
chunk_bounds[3] / elev_res * j,
0,
)
drive_mask_cube = solid2.cube([
chunk_bounds[2] / elev_res,
chunk_bounds[3] / elev_res,
elev_grid[i, j] + baseplate_h + ROAD_HEIGHT,
]).translate(
chunk_bounds[2] / elev_res * i,
chunk_bounds[3] / elev_res * j,
0,
)
if drive_mask is None:
drive_mask = drive_mask_cube
else:
drive_mask += drive_mask_cube
print(f"[{self.output}] Constructing networks...")
drive_polygons = []
for shape in drive_net["geometry"]:
shape = convert_shape_from_world_coords(shape).simplify(1).buffer(0.25)
drive_polygons.append(shape.intersection(chunk_rect))
drive_polygons = [shape for shape in drive_polygons if not shape.is_empty]
drive_polygons = union_all(drive_polygons)
if drive_polygons.geom_type == "Polygon":
drive_polygons = MultiPolygon([drive_polygons])
for shape in drive_polygons.geoms:
model += solid2.linear_extrude(100)(to_scad_polygon(shape)).translate(0, 0, baseplate_h) * drive_mask
print(f"[{self.output}] Constructing buildings...")
for i, building in buildings.iterrows():
shape = convert_shape_from_world_coords(building["geometry"])
shape = shape.buffer(0.125)
# Simplifying building shapes after scaling can speed up rendering by
# an order of magnitude
shape = shape.simplify(0.05)
shape = shape.intersection(chunk_rect)
if not shape.is_empty:
height = get_building_height(building) * scale_factor
building_center = building["geometry"].centroid
# Computing elevation from the elevation map can result in weird
# artifacts, so calculate based on the less granular precomputed
# grid instead
elev = elev_grid[
min(max(int((building_center.x - map_bounds[0]) / map_width * elev_res), 0), elev_res - 1),
min(max(int((building_center.y - map_bounds[1]) / map_height * elev_res), 0), elev_res - 1),
]
if shape.geom_type == "Polygon":
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
else:
for shape in shape.geoms:
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
print(f"[{self.output}] Rendering...")
render_stl(model, self.output)
print(f"[{self.output}] Chunk finished.")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
@ -86,6 +266,18 @@ if __name__ == "__main__":
help="<min lon>,<min lat>,<max lon>,<max lat>",
required=True,
)
parser.add_argument(
"--elev-exaggeration",
type=float,
help="Additional scaling factor to apply to elevations for dramatic effect",
default=1.0,
)
parser.add_argument(
"--slices",
type=int,
help="Parallelize render and increase elevation resolution by splitting model into n tiles per side (--slices=3 splits work into 9 tiles)",
default=ceil(sqrt(cpu_count())),
)
parser.add_argument(
"--edge-len",
type=float,
@ -93,9 +285,9 @@ if __name__ == "__main__":
help="Long edge length of output STL bounding box",
)
parser.add_argument(
"--output",
help="File path for STL output",
required=True,
"--square",
action="store_true",
help="Constrains the model to a square with dimension --edge-len",
)
parser.add_argument(
"--baseplate-h",
@ -104,20 +296,17 @@ if __name__ == "__main__":
help="Depth of the base plate in millimeters",
)
parser.add_argument(
"--circular",
action="store_true",
"--output",
help="File path for STL output",
required=True,
)
args = parser.parse_args()
t_start = datetime.now()
elev_map = ElevationMap(args.topo)
osm = OSM(args.osm)
solid2.set_global_fn(48)
drive_net = osm.get_network(network_type="driving")
natural = osm.get_natural()
buildings = osm.get_buildings()
map_bounds = [float(coord) for coord in args.bbox.split(",")]
map_bounds = tuple(float(coord) for coord in args.bbox.split(","))
map_width = map_bounds[2] - map_bounds[0]
map_height = map_bounds[3] - map_bounds[1]
@ -139,6 +328,22 @@ if __name__ == "__main__":
map_width_meters = map_width * meters_per_unit_lng
map_height_meters = map_height * meters_per_unit_lat
if args.square:
map_center = (
(map_bounds[0] + map_bounds[2]) / 2,
(map_bounds[1] + map_bounds[3]) / 2,
)
short_edge_meters = min(map_width_meters, map_height_meters)
map_bounds = (
map_center[0] - short_edge_meters / meters_per_unit_lng / 2,
map_center[1] - short_edge_meters / meters_per_unit_lat / 2,
map_center[0] + short_edge_meters / meters_per_unit_lng / 2,
map_center[1] + short_edge_meters / meters_per_unit_lat / 2,
)
map_width_meters = short_edge_meters
map_height_meters = short_edge_meters
aspect_ratio = map_width_meters / map_height_meters
if aspect_ratio > 1:
# Wide
@ -146,12 +351,6 @@ if __name__ == "__main__":
else:
# Tall
model_bounds = (0, 0, args.edge_len * aspect_ratio, args.edge_len)
model_rect = Polygon([
(0, 0),
(0, model_bounds[3]),
(model_bounds[2], model_bounds[3]),
(model_bounds[2], 0),
])
# Scale factor from real-world meters to the model coordinate system
scale_factor = model_bounds[2] / map_width_meters
@ -166,99 +365,51 @@ if __name__ == "__main__":
f"{scale_factor:.4g}",
)
def convert_shape_from_world_coords(shape):
# Move the coordinate system so that the origin is at the bottom left
# of the map rectangle, then scale the shape in x and y directions
return affinity.scale(
affinity.translate(shape, -map_bounds[0], -map_bounds[1]),
model_bounds[2] / map_width,
model_bounds[3] / map_height,
origin=(0, 0),
)
model = solid2.cube([model_bounds[2] - model_bounds[0], model_bounds[3] - model_bounds[1], args.baseplate_h])
print("Calculating elevations...")
# Slice the model area into a rectilinear grid to approximate topography
ELEV_RES = 30
elev_grid = np.zeros((ELEV_RES, ELEV_RES))
for i in range(ELEV_RES):
for j in range(ELEV_RES):
elev_grid[i, j] = elev_map.get_elevation(
map_bounds[1] + (map_bounds[3] - map_bounds[1]) / ELEV_RES * j,
map_bounds[0] + (map_bounds[2] - map_bounds[0]) / ELEV_RES * i,
)
min_world_elev = elev_grid.min()
elev_grid = (elev_grid - min_world_elev) * scale_factor
elev_grid = get_elev_grid(elev_map, map_bounds, args.slices * 25, scale_factor)
elev_grid *= args.elev_exaggeration
ROAD_HEIGHT = 0.25
# Render roads on slopes by over-extruding them and then calculating their
# intersection with a slightly offset elevation grid
drive_mask = None
for i in range(ELEV_RES):
for j in range(ELEV_RES):
if elev_grid[i, j] > 0:
model += solid2.cube([
model_bounds[2] / ELEV_RES,
model_bounds[3] / ELEV_RES,
elev_grid[i, j] + args.baseplate_h,
]).translate(
model_bounds[2] / ELEV_RES * i,
model_bounds[3] / ELEV_RES * j,
renderers = []
for i in range(args.slices):
for j in range(args.slices):
renderers.append(Renderer(
map_bounds=(
map_bounds[0] + map_width / args.slices * i,
map_bounds[1] + map_height / args.slices * j,
map_bounds[0] + map_width / args.slices * (i + 1),
map_bounds[1] + map_height / args.slices * (j + 1),
),
chunk_bounds=(
0,
)
drive_mask_cube = solid2.cube([
model_bounds[2] / ELEV_RES,
model_bounds[3] / ELEV_RES,
elev_grid[i, j] + args.baseplate_h + ROAD_HEIGHT,
]).translate(
model_bounds[2] / ELEV_RES * i,
model_bounds[3] / ELEV_RES * j,
0,
)
if drive_mask is None:
drive_mask = drive_mask_cube
else:
drive_mask += drive_mask_cube
model_bounds[2] / args.slices,
model_bounds[3] / args.slices,
),
scale_factor=scale_factor,
osm_path=args.osm,
elev_grid=elev_grid[
int(elev_grid.shape[0]/args.slices*i):int(elev_grid.shape[0]/args.slices*(i+1)),
int(elev_grid.shape[1]/args.slices*j):int(elev_grid.shape[1]/args.slices*(j+1)),
],
baseplate_h=args.baseplate_h,
output=f"/tmp/chunk.{i}.{j}.stl",
))
with Pool(cpu_count()) as pool:
pool.map(Renderer.render_chunk, renderers)
print("Constructing networks...")
drive_polygons = []
for shape in drive_net["geometry"]:
shape = convert_shape_from_world_coords(shape).simplify(1).buffer(0.25)
drive_polygons.append(shape.intersection(model_rect))
drive_polygons = [shape for shape in drive_polygons if not shape.is_empty]
for shape in union_all(drive_polygons).geoms:
model += solid2.linear_extrude(100)(to_scad_polygon(shape)).translate(0, 0, args.baseplate_h) * drive_mask
print("Combining...")
chunks = []
for i in range(args.slices):
for j in range(args.slices):
mesh = stl.Mesh.from_file(f"/tmp/chunk.{i}.{j}.stl")
mesh.x += model_bounds[2] / args.slices * i
mesh.y += model_bounds[3] / args.slices * j
chunks.append(mesh)
# This doesn't actually perform a union calculation but rather lines up
# multiple meshes alongside each other, which as long as it's good enough
# for a gcode slicer...
stl.Mesh(np.concatenate([chunk.data for chunk in chunks])).save(args.output, mode=stl.Mode.BINARY)
print("Constructing buildings...")
for i, building in buildings.iterrows():
shape = convert_shape_from_world_coords(building["geometry"])
shape = shape.buffer(0.125)
# Simplifying building shapes after scaling can speed up rendering by
# an order of magnitude
shape = shape.simplify(0.05)
shape = shape.intersection(model_rect)
if not shape.is_empty:
height = get_building_height(building) * scale_factor
building_center = building["geometry"].centroid
# Computing elevation from the elevation map can result in weird
# artifacts, so calculate based on the less granular precomputed
# grid instead
elev = elev_grid[
min(max(int((building_center.x - map_bounds[0]) / map_width * ELEV_RES), 0), ELEV_RES - 1),
min(max(int((building_center.y - map_bounds[1]) / map_height * ELEV_RES), 0), ELEV_RES - 1),
]
if shape.geom_type == "Polygon":
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
else:
for shape in shape.geoms:
model += solid2.linear_extrude(args.baseplate_h + elev + ROAD_HEIGHT + height)(to_scad_polygon(shape))
if args.circular:
radius = min(model_bounds[2], model_bounds[3]) / 2
model *= solid2.translate(model_bounds[2] / 2, model_bounds[3] / 2, 0)(
solid2.cylinder(100, r=radius),
)
print("Rendering... (grab a cup of coffee)")
solid2.render_to_stl_file(model, args.output)
duration = datetime.now() - t_start
print(f"Finished in: {duration}")