Source code for simulation.utils.road.renderer.tile

import errno
import hashlib
import os
import random
from dataclasses import dataclass, field

import cairo
from kitcar_utils.geometry import Polygon, Transform, Vector

import simulation.utils.road.renderer.surface_markings as render_surface_markings
import simulation.utils.road.renderer.utils as utils
from simulation.utils.road.sections.road_section import RoadSection


[docs] @dataclass class Tile: """Piece of the groundplane with lines used to display road lines on the ground. The groundplane in simulation is made out of many rectangular tiles. Each tile displays an image on the ground. """ COUNTER = random.randint(0, 10**20) """Static variable used to generate unique model names in Gazebo. To ensure that Gazebo doesn't get confused with multiple tiles that have the same name, e.g. when reloading, the counter is increased every time a model name is generated. """ index: tuple[int, int] """Position within the lattice of tiles on the groundplane.""" size: Vector """Size of the tile.""" resolution: Vector """Resolution of the tile's image.""" road_folder_name: str = field(default=None, repr=False) """Name of the folder in which all tiles of the current road are. (Not the complete path, just the name of the folder!) """ sections: dict[int, RoadSection] = field(default_factory=set) """All sections that are (atleast partly) on this tile.""" id: str = None """ID of the tile. Automatically generated when rendering. """ already_rendered: bool = False """Indicate whether the tile has been rendered before.""" png_path: str = field(default=None, repr=False) """Absolute path to the rendered PNG. Set during :meth:`render_to_file` (or :meth:`resolve_png_path` for previously rendered tiles) and referenced as the PBR albedo map in :meth:`get_model_string`. """ @property def name(self) -> str: """str: Name of the tile's model when spawned in Gazebo.""" return f"tile_{self.index[0]}x{self.index[1]}" @property def transform(self) -> Transform: """Transform: Transform to the center of the tile.""" return Transform([(self.index[0]) * self.size.x, (self.index[1]) * self.size.y], 0) @property def frame(self) -> Polygon: """Polygon: Frame of the tile.""" return ( self.transform * Transform([-self.size.x / 2, -self.size.y / 2], 0) * Polygon([[0, 0], [self.size.x, 0], self.size, [0, self.size.y]]) )
[docs] def get_model_string(self) -> str: """Get a model string that can be spawned in Gazebo. Gazebo Harmonic does not support Ogre material scripts, so the rendered PNG is attached as a PBR albedo map referenced by its absolute file path. The textured surface is a flat plane *mesh* (with explicit UV coordinates) rather than a ``<box>`` or ``<plane>`` primitive: gz Harmonic's Ogre2 backend does not generate texture coordinates for primitive geometry, so a PBR ``<albedo_map>`` on a box or plane is never sampled and the surface renders as the flat base colour (black). A mesh carrying its own UVs is the only geometry whose albedo map is applied. """ Tile.COUNTER += 1 mesh_path = self._write_plane_mesh() return f""" <model name='{self.name + "x" + str(Tile.COUNTER)}'> <static>1</static> <link name='link'> <collision name='collision'> <geometry> <plane> <normal>0 0 1</normal> <size>{self.size.x} {self.size.y}</size> </plane> </geometry> <surface> <friction> <ode> <mu>100</mu> <mu2>50</mu2> </ode> <torsional> <ode/> </torsional> </friction> <contact> <ode/> </contact> <bounce/> </surface> <max_contacts>10</max_contacts> </collision> <visual name='visual'> <cast_shadows>0</cast_shadows> <!-- Lift the textured patch just above the ground plane so it occludes the editor grid (which sits at z=0) and the lane markings stay crisp. --> <pose>0 0 0.005 0 0 0</pose> <geometry> <mesh> <uri>{mesh_path}</uri> <scale>{self.size.x} {self.size.y} 1</scale> </mesh> </geometry> <material> <diffuse>1 1 1 1</diffuse> <pbr> <metal> <albedo_map>{self.png_path}</albedo_map> <metalness>0.0</metalness> <roughness>1.0</roughness> </metal> </pbr> </material> </visual> <self_collide>0</self_collide> <enable_wind>0</enable_wind> <kinematic>0</kinematic> </link> <pose>{self.transform.translation.x} {self.transform.translation.y} 0 0 -0 0</pose> </model> """
[docs] def _write_plane_mesh(self) -> str: """Write (once) a unit 1x1 plane OBJ with UVs next to the tile PNG and return it. The plane lies in the z=0 plane, centred at the origin, spanning [-0.5, 0.5] in x and y; the model scales it to the tile size. UVs map the texture so that +x is to the right and +y is up, matching the cairo render in :meth:`render_to_file`. """ mesh_path = os.path.join(os.path.dirname(self.png_path), "tile_plane.obj") if not os.path.exists(mesh_path): with open(mesh_path, "w") as f: f.write( "v -0.5 -0.5 0\n" "v 0.5 -0.5 0\n" "v 0.5 0.5 0\n" "v -0.5 0.5 0\n" "vt 0 0\n" "vt 1 0\n" "vt 1 1\n" "vt 0 1\n" "vn 0 0 1\n" "f 1/1/1 2/2/1 3/3/1\n" "f 1/1/1 3/3/1 4/4/1\n" ) return mesh_path
[docs] def resolve_png_path(self, roads_path: str): """Set :attr:`png_path` for an already-rendered tile (id and PNG already exist).""" self.png_path = os.path.join( roads_path, self.road_folder_name, self.id, self.id + ".png" )
[docs] def render_to_file(self, roads_path: str): """Render an image of the tile and save it to a file. Args: roads_path: Directory in which all roads are located. """ surface = cairo.ImageSurface( cairo.FORMAT_RGB24, int(self.resolution.x), int(self.resolution.y) ) ctx = cairo.Context(surface) # Adjust scale ctx.scale(self.resolution.x / self.size.x, self.resolution.y / self.size.y) # Inverse y-axis ctx.translate(0, self.size.y / 2) ctx.scale(1, -1) ctx.translate(0, -self.size.y / 2) # Move to center of the tile ctx.translate(self.size.x / 2, self.size.y / 2) # Create black background ctx.set_source_rgb(0, 0, 0) ctx.rectangle(0, 0, self.size.x, self.size.y) ctx.fill() # Invert the render transform ctx.translate(-self.transform.translation.x, -self.transform.translation.y) # Draw lines for all sections for sec in self.sections.values(): for line in sec.lines: utils.draw_line(ctx, line) for marking in sec.surface_markings: render_surface_markings.draw(ctx, marking) sha_256 = hashlib.sha256() sha_256.update(surface.get_data()) hash = sha_256.hexdigest() self.id = f"tile-{hash}" dir = os.path.join(roads_path, self.road_folder_name, self.id) if not os.path.exists(dir): try: os.makedirs(dir) except OSError as exc: # Guard against race condition if exc.errno != errno.EEXIST: raise self.png_path = os.path.join(dir, self.id + ".png") surface.write_to_png(self.png_path)