Source code for configuration.models.initial_agents

"""Module defining the InitialPedestrian and InitialBike classes."""

# Copyright  2025  Institute of Light and Matter, CNRS UMR 5306, University Claude Bernard Lyon 1
# Contributors: Oscar DUFOUR, Maxime STAPELLE, Alexandre NICOLAS

# This software is a computer program designed to generate a realistic crowd from anthropometric data and
# simulate the mechanical interactions that occur within it and with obstacles.

# This software is governed by the CeCILL-B license under French law and abiding by the rules of distribution
# of free software.  You can  use, modify and/ or redistribute the software under the terms of the CeCILL-B
# license as circulated by CEA, CNRS and INRIA at the following URL "http://www.cecill.info".

# As a counterpart to the access to the source code and  rights to copy, modify and redistribute granted by
# the license, users are provided only with a limited warranty  and the software's author,  the holder of the
# economic rights,  and the successive licensors  have only  limited liability.

# In this respect, the user's attention is drawn to the risks associated with loading,  using,  modifying
# and/or developing or reproducing the software by the user in light of its specific status of free software,
# that may mean  that it is complicated to manipulate,  and  that  also therefore means  that it is reserved
# for developers  and  experienced professionals having in-depth computer knowledge. Users are therefore
# encouraged to load and test the software's suitability as regards their requirements in conditions enabling
# the security of their systems and/or data to be ensured and,  more generally, to use and operate it in the
# same conditions as regards security.

# The fact that you are presently reading this means that you have had knowledge of the CeCILL-B license and that
# you accept its terms.

from pathlib import Path

from shapely.geometry import MultiPolygon, Point, box
from shapely.ops import unary_union

import configuration.utils.constants as cst
from configuration.utils import functions as fun
from configuration.utils.typing_custom import Sex, ShapeDataType, ShapeType


[docs] class InitialPedestrian: """ Class representing the initial pedestrian state including its 2D shape data and basic measurements. Parameters ---------- sex : Sex Biological sex of the pedestrian, must be either "male" or "female". """ def __init__(self, sex: Sex) -> None: """ Initialize a pedestrian agent with biomechanical properties. Parameters ---------- sex : Sex Biological sex of the pedestrian, must be either "male" or "female". Attributes ---------- _agent_type : AgentTypes Agent type set to pedestrian _shapes2D : ShapeDataType 2D shape object _shapes3D : dict[float, MultiPolygon] 3D body layers mapped to z-height coordinates _measures : dict[str, float | Sex | None] Biomechanical measurements including: - sex: Biological sex (Literal["male","female"]) - bideltoid_breadth: Shoulder width derived from disk4 - chest_depth: Torso depth from disk2 - height: Vertical span of 3D body - weight: Default weight from constants file - moment_of_inertia: Initially uncalculated (None) Notes ----- - 2D and 3D coordinates are in centimeters - The moment_of_inertia will computed later when the agent will be created """ if isinstance(sex, str) and sex not in ["male", "female"]: raise ValueError("The sex should be either 'male' or 'female'.") self._agent_type: cst.AgentTypes = cst.AgentTypes.pedestrian self._shapes2D: ShapeDataType = self._initialize_shapes() dir_path = Path(__file__).parent.parent.parent.parent.absolute() / "data" / "pkl" self._shapes3D: dict[float, MultiPolygon] = fun.load_pickle(str(dir_path / f"{sex}_3dBody_light.pkl")) # Initialize measures bideltoid_breadth: float = 0.0 if ( isinstance(self._shapes2D["disk4"]["x"], float) and isinstance(self._shapes2D["disk4"]["y"], float) and isinstance(self._shapes2D["disk4"]["radius"], float) ): bideltoid_breadth = 2.0 * self._shapes2D["disk4"]["x"] + 2.0 * self._shapes2D["disk4"]["radius"] chest_depth: float = 0.0 if isinstance(self._shapes2D["disk2"]["radius"], float): chest_depth = 2.0 * self._shapes2D["disk2"]["radius"] self._measures: dict[str, float | Sex | None] = { cst.PedestrianParts.sex.name: sex, cst.PedestrianParts.bideltoid_breadth.name: bideltoid_breadth, cst.PedestrianParts.chest_depth.name: chest_depth, cst.PedestrianParts.height.name: abs(max(self._shapes3D.keys()) - min(self._shapes3D.keys())), cst.CommonMeasures.weight.name: cst.DEFAULT_PEDESTRIAN_WEIGHT, cst.CommonMeasures.moment_of_inertia.name: None, } # Center the initial shapes around (0, 0) self._center_initial_shapes2D() def _initialize_shapes(self) -> dict[str, dict[str, ShapeType | float | tuple[float, float]]]: """ Initialize shape data. Creates and configures five circular disks representing key body features: - Disk0: Left arm - Disk1: Left pectoral muscle - Disk2: Central belly - Disk3: Right pectoral muscle - Disk4: Right arm Returns ------- dict[str, dict[str, ShapeType | float | tuple[float, float]]] Nested dictionary containing circular shape data with: - Outer keys: Component IDs (e.g., "disk0" to "disk4") - Inner dictionaries containing: * type: "disk" (str from ShapeTypes enum name) * radius: Disk radius (cm) * material: Material name (str from MaterialNames enum) * x: x-coordinate (cm) * y: y-coordinate (cm) Notes ----- - Initial coordinates are defined in image pixels before conversion - Defaults to (0.0, 0.0) center and 0.0 radius if invalid data types found - Disk positions correspond to anatomical features: - Disk2 represents central belly - Disks 1/3 represent left/right pectoral muscle - Disks 0/4 represent left/right arms """ # Define disk parameters (center coordinates and radii) disks: list[dict[str, tuple[float, float] | float]] = [ {"center": (-552.7920, 0.00000), "radius": 282.41232}, {"center": (-242.5697, 71.73901), "radius": 388.36243}, {"center": (0.0000, 86.11438), "radius": 405.97552}, {"center": (242.5697, 71.73901), "radius": 388.36243}, {"center": (552.7920, 0.00000), "radius": 282.41232}, ] # Convert disk data into a structured dictionary return { f"disk{i}": { "type": cst.ShapeTypes.disk.name, "radius": disk["radius"] * cst.PIXEL_TO_CM_PEDESTRIAN if isinstance(disk["radius"], float) else 0.0, "material": cst.MaterialNames.human_naked.name, "x": disk["center"][0] * cst.PIXEL_TO_CM_PEDESTRIAN if isinstance(disk["center"], tuple) else 0.0, "y": disk["center"][1] * cst.PIXEL_TO_CM_PEDESTRIAN if isinstance(disk["center"], tuple) else 0.0, } for i, disk in enumerate(disks) } @property def sex(self) -> Sex: """ Get the biological sex of the pedestrian. Returns ------- Sex The biological sex of the pedestrian as either "male" or "female". Raises ------ ValueError If provided value is not "male" or "female" TypeError If non-string value is provided """ sex_name = self._measures[cst.PedestrianParts.sex.name] if isinstance(sex_name, str) and sex_name in ["male", "female"]: return sex_name raise TypeError(f"Expected type 'str' with value 'male' or 'female', but got {type(sex_name).__name__}") @property def agent_type(self) -> cst.AgentTypes: """ Get the type of the agent. Returns ------- AgentTypes Enum member representing the agent's type classification (either "pedestrian", "bike" or "custom"). """ return self._agent_type @property def shapes2D(self) -> ShapeDataType: """ Get the 2D geometric representation of the pedestrian's body components. Returns ------- ShapeDataType Dictionary containing circular disk representations of body parts with: - Keys: Format "disk{N}" where N ranges 0-4 (e.g., "disk0", "disk1") - Values: Dictionaries with properties: * type: "disk" (str from ShapeTypes enum name) * radius: Disk radius (cm) * material: Material name (str from MaterialNames enum) * x: x-coordinate (cm) * y: y-coordinate (cm) """ return self._shapes2D @property def measures(self) -> dict[str, float | Sex | None]: """ Get the measures of the pedestrian. Returns ------- dict[str, float | Sex | None] A dictionary containing the pedestrian's measures. The keys are measure name. """ return self._measures @property def shapes3D(self) -> dict[float, MultiPolygon]: """ Get the 3D body representation of the pedestrian. Returns ------- dict[float, MultiPolygon] A dictionary where: - Keys are float representing the height of each pedestrian slice. - Values are "MultiPolygon" objects representing the 2D geometry of each layer or slice. """ return self._shapes3D def _center_initial_shapes2D(self) -> None: """Center the initial 2D shapes of the pedestrian to center them around (0, 0).""" center_of_mass = self.get_position() for shape in self.shapes2D.values(): shape["x"] -= center_of_mass.x shape["y"] -= center_of_mass.y
[docs] def get_position(self) -> Point: """ Get the centroid position of the pedestrian based on their 2D shapes. Returns ------- Point A Point object representing the centroid of the pedestrian's geometry. """ # Create buffered shapes from the 2D shape definitions buffered_shapes = [ Point(shape["x"], shape["y"]).buffer(shape["radius"], quad_segs=cst.DISK_QUAD_SEGS) for shape in self.shapes2D.values() ] if not buffered_shapes: raise ValueError("No shapes defined for the pedestrian.") # Compute the union of all shapes combined_shape = unary_union(buffered_shapes) # Return the centroid as a Point return combined_shape.centroid
[docs] def get_disk_centers(self) -> list[Point]: """ Retrieve the center coordinates of all disks of the agent physical shape. Returns ------- list[Point] A list of Point objects representing the center coordinates of each disk (cm). The points are returned in the order of disk indices (disk0, disk1, ..., diskN-1). """ return [Point(self.shapes2D[f"disk{i}"]["x"], self.shapes2D[f"disk{i}"]["y"]) for i in range(cst.DISK_NUMBER)]
[docs] def get_disk_radii(self) -> list[float]: """ Retrieve the radii of all disks of the agent physical shape. Returns ------- list[float] A list containing the radii of the disks in the order they are stored. """ list_of_radii: list[float] = [] for i in range(cst.DISK_NUMBER): radius = self.shapes2D[f"disk{i}"]["radius"] if isinstance(radius, float): list_of_radii.append(radius) return list_of_radii
[docs] def get_reference_multipolygon(self) -> MultiPolygon: """ Get the reference multipolygon of the agent i.e. the one at torso height. Returns ------- MultiPolygon The reference multipolygon of the agent. """ smallest_height = min(self.shapes3D.keys()) largest_height = max(self.shapes3D.keys()) - smallest_height theoretical_torso_height = largest_height * cst.HEIGHT_OF_BIDELTOID_OVER_HEIGHT + smallest_height closest_height = min(self.shapes3D.keys(), key=lambda x: abs(float(x) - theoretical_torso_height)) multip = self.shapes3D[closest_height] return multip
[docs] def get_bideltoid_breadth(self) -> float: """ Compute the bideltoid breadth of the agent (that has not rotated) in cm. Returns ------- float The bideltoid breadth of the agent (cm). """ if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("get_bideltoid_breadth() can only be used for pedestrian agents.") reference_multipolygon = self.get_reference_multipolygon() return float(fun.compute_bideltoid_breadth_from_multipolygon(reference_multipolygon))
[docs] def get_chest_depth(self) -> float: """ Compute the chest depth of the agent (that has not rotated) in cm. Returns ------- float The chest depth of the agent (cm). """ if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("get_chest_depth() can only be used for pedestrian agents.") reference_multipolygon = self.get_reference_multipolygon() return float(fun.compute_chest_depth_from_multipolygon(reference_multipolygon))
[docs] def get_height(self) -> float: """ Compute the height of the agent (cm). Returns ------- float The height of the agent (cm). """ if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("get_height() can only be used for pedestrian agents.") shapes3D_dict = self.shapes3D lowest_height = min(float(height) for height in shapes3D_dict.keys()) highest_height = max(float(height) for height in shapes3D_dict.keys()) return highest_height - lowest_height
[docs] class InitialBike: """Class representing the initial state of a bike, including its 2D shape data and derived measurements.""" def __init__(self) -> None: """ Initialize an instance of the InitialBike class. Attributes ---------- _agent_type : AgentTypes The type of agent, initialized to 'bike'. _shapes2D : ShapeDataType The 2D shape data initialized by the "_initialize_shapes" method. _measures : dict[str, float | None] A dictionary containing measurements derived from the shape data. Keys are defined in BikeParts and CommonMeasures enums: - 'wheel_width': The width of the bike's wheel. - 'total_length': The total length of the bike. - 'handlebar_length': The length of the rider's handlebar. - 'top_tube_length': The length of the rider's top tube. - 'weight': The default weight of the bike (set to DEFAULT_BIKE_WEIGHT). - 'moment_of_inertia': The moment of inertia (initially set to None). Notes ----- - All measurements are calculated in centimeters. - The moment of inertia is initially set to None and should be calculated separately. """ self._agent_type: cst.AgentTypes = cst.AgentTypes.bike self._shapes2D: ShapeDataType = self._initialize_shapes() # Initialize measures wheel_width: float = 0.0 if isinstance(self._shapes2D["bike"]["max_x"], float) and isinstance(self._shapes2D["bike"]["min_x"], float): wheel_width = self._shapes2D["bike"]["max_x"] - self._shapes2D["bike"]["min_x"] total_length: float = 0.0 if isinstance(self._shapes2D["bike"]["max_y"], float) and isinstance(self._shapes2D["bike"]["min_y"], float): total_length = self._shapes2D["bike"]["max_y"] - self._shapes2D["bike"]["min_y"] handlebar_length: float = 0.0 if isinstance(self._shapes2D["rider"]["max_x"], float) and isinstance(self._shapes2D["rider"]["min_x"], float): handlebar_length = self._shapes2D["rider"]["max_x"] - self._shapes2D["rider"]["min_x"] top_tube_length: float = 0.0 if isinstance(self._shapes2D["rider"]["max_y"], float) and isinstance(self._shapes2D["rider"]["min_y"], float): top_tube_length = self._shapes2D["rider"]["max_y"] - self._shapes2D["rider"]["min_y"] self._measures: dict[str, float | None] = { cst.BikeParts.wheel_width.name: wheel_width, cst.BikeParts.total_length.name: total_length, cst.BikeParts.handlebar_length.name: handlebar_length, cst.BikeParts.top_tube_length.name: top_tube_length, cst.CommonMeasures.weight.name: cst.DEFAULT_BIKE_WEIGHT, cst.CommonMeasures.moment_of_inertia.name: None, } # Center the initial shapes around (0, 0) self._center_initial_shapes2D() def _initialize_shapes(self) -> dict[str, dict[str, ShapeType | float]]: """ Initialize 2D shape data for the bicycle and rider. Creates rectangular shapes for both bicycle and rider, then: 1. Calculates their original center of mass 2. Adjusts coordinates to set center of mass at (0,0) 3. Converts coordinates from pixels to centimeters using PIXEL_TO_CM_BIKE Returns ------- dict[str, dict[str, ShapeType | float]] Nested dictionary containing shape data with two keys: - "bike": Dictionary of bicycle shape properties - "rider": Dictionary of rider shape properties Each shape dictionary contains: - type: ShapeTypes.rectangle.name (str) - material: MaterialNames.iron.name (str) - min_x: Minimum x-coordinate (cm) - min_y: Minimum y-coordinate (cm) - max_x: Maximum x-coordinate (cm) - max_y: Maximum y-coordinate (cm) """ # define the bike as a rectangle rider = { "min_x": 62.0, "min_y": 96.0, "max_x": 62.0 + 86.0, "max_y": 96.0 + 99.0, } # define the rider as a rectangle orthogonal to the bike bike = { "min_x": 96.0, "min_y": 46.0, "max_x": 96.0 + 16.0, "max_y": 46.0 + 204.0, } # put the CM to (0,0) and convert to cm center_of_mass_bike = Point((bike["min_x"] + bike["max_x"]) / 2.0, (bike["min_y"] + bike["max_y"]) / 2.0) center_of_mass_rider = Point( (rider["min_x"] + rider["max_x"]) / 2.0, (rider["min_y"] + rider["max_y"]) / 2.0, ) bike["min_x"] = bike["min_x"] - center_of_mass_bike.x bike["min_y"] = bike["min_y"] - center_of_mass_bike.y bike["max_x"] = bike["max_x"] - center_of_mass_bike.x bike["max_y"] = bike["max_y"] - center_of_mass_bike.y rider["min_x"] = rider["min_x"] - center_of_mass_rider.x rider["min_y"] = rider["min_y"] - center_of_mass_rider.y rider["max_x"] = rider["max_x"] - center_of_mass_rider.x rider["max_y"] = rider["max_y"] - center_of_mass_rider.y for key in bike: bike[key] = bike[key] * cst.PIXEL_TO_CM_BIKE for key in rider: rider[key] = rider[key] * cst.PIXEL_TO_CM_BIKE return { "bike": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.concrete.name, "min_x": bike["min_x"], "min_y": bike["min_y"], "max_x": bike["max_x"], "max_y": bike["max_y"], }, "rider": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.human_clothes.name, "min_x": rider["min_x"], "min_y": rider["min_y"], "max_x": rider["max_x"], "max_y": rider["max_y"], }, } @property def agent_type(self) -> cst.AgentTypes: """ Get the type of the agent. Returns ------- AgentTypes Enum member representing the agent's type classification (either "pedestrian", "bike" or "custom"). """ # return error if the agent type is not bike if self._agent_type != cst.AgentTypes.bike: raise ValueError("The agent type is not bike.") return self._agent_type @property def shapes2D(self) -> ShapeDataType: """ Get the 2D shapes of the agent. Returns ------- ShapeDataType An object containing the 2D shapes of the agent. """ return self._shapes2D @property def measures(self) -> dict[str, float | None]: """ Get the measures of the agent. Returns ------- dict[str, float | None] A dictionary containing the measures of the agent. Keys are measure names and values are measure values. """ return self._measures
[docs] def get_position(self) -> Point: """ Compute the centroid position of the agent based on all 2D shapes. Returns ------- Point A Point object representing the centroid of the agent's geometry. Raises ------ ValueError If no valid shapes are found in self.shapes2D. """ polygons = [] for shape_name, shape in self.shapes2D.items(): try: min_x = shape["min_x"] min_y = shape["min_y"] max_x = shape["max_x"] max_y = shape["max_y"] polygons.append(box(min_x, min_y, max_x, max_y)) except KeyError as e: raise ValueError(f"Missing key {e} in shape '{shape_name}'") from e if not polygons: raise ValueError("No valid shapes found to compute centroid.") combined_shape = unary_union(polygons) return combined_shape.centroid
def _center_initial_shapes2D(self) -> None: """Center the initial 2D shapes of the bike to center them around (0, 0).""" center_of_mass = self.get_position() for shape in self.shapes2D.values(): shape["min_x"] -= center_of_mass.x shape["min_y"] -= center_of_mass.y shape["max_x"] -= center_of_mass.x shape["max_y"] -= center_of_mass.y