Source code for configuration.models.shapes2D

"""Class to store body shapes based on agent type."""

# 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 dataclasses import dataclass, field
from typing import Any

import numpy as np
from numpy.typing import NDArray
from scipy.optimize import dual_annealing
from shapely.affinity import scale
from shapely.geometry import MultiPolygon, Point, Polygon
from shapely.ops import unary_union

import configuration.utils.constants as cst
import configuration.utils.functions as fun
from configuration.models.initial_agents import InitialBike, InitialPedestrian
from configuration.models.measures import AgentMeasures
from configuration.utils.typing_custom import MaterialType, ShapeDataType, ShapeType


[docs] @dataclass class Shapes2D: """ Class to store body shapes based on agent type. This class allows you to manage shapes in two ways: 1. Provide a dictionary of pre-defined Shapely shapes as input 2. Specify the type of shape and its characteristics to create it """ agent_type: cst.AgentTypes shapes: ShapeDataType = field(default_factory=dict) def __post_init__(self) -> None: """ Validate the provided shapes and agent type after initialization. Raises ------ ValueError If the agent type is not one of the allowed values defined in `AgentType`. ValueError If the `shapes` attribute is not a dictionary. ValueError If any shape in the `shapes` dictionary is not a valid Shapely object (Point or Polygon). ValueError If the reference direction is not within the range (-180.0, 180.0]. """ # Validate the provided agent type if not isinstance(self.agent_type, cst.AgentTypes): raise ValueError(f"Agent type should be one of: {[member.name for member in cst.AgentTypes]}.") # Validate the provided shapes if not isinstance(self.shapes, dict): raise ValueError("shapes should be a dictionary.") # Validate that the provided shapes are valid Shapely objects for shape_name, shape in self.shapes.items(): if not isinstance(shape.get("object"), (Point, Polygon)): raise ValueError(f"Invalid shape type for '{shape_name}': {type(shape.get('object'))}")
[docs] def add_shape(self, name: str, shape_type: ShapeType, material: MaterialType, **kwargs: Any) -> None: r""" Create a shape and add it to the shapes dictionary. Parameters ---------- name : str The name of the shape. shape_type : ShapeType The type of the shape. Must be one of the following: {'disk', 'rectangle', 'polygon'}. material : MaterialType The material of the shape. \*\*kwargs : Any Additional keyword arguments specific to the shape type: - **Disk**: - `x` (float): The x-coordinate of the disk's center. - `y` (float): The y-coordinate of the disk's center. - `radius` (float): The radius of the disk. - `material` (str): The material of the disk. - **Rectangle**: - `min_x` (float): The minimum x-coordinate of the rectangle. - `min_y` (float): The minimum y-coordinate of the rectangle. - `max_x` (float): The maximum x-coordinate of the rectangle. - `max_y` (float): The maximum y-coordinate of the rectangle. - `material` (str): The material of the rectangle. - **Polygon**: - `points` (list of tuple[float, float]): A list of `(x, y)` coordinates representing the vertices of the polygon. Must contain at least 3 points, and the first and last points must match to close the polygon. - `material` (str): The material of the polygon. Raises ------ ValueError If the shape type is unsupported or if required keyword arguments are missing or invalid. Notes ----- This method validates that all required parameters are provided and ensures that shapes are correctly formatted before adding them to the dictionary. """ fun.validate_material(material) if shape_type == cst.ShapeTypes.disk.name: center = (kwargs.get("x"), kwargs.get("y")) radius = kwargs.get("radius") if not isinstance(center, tuple) or not isinstance(radius, (int, float)): raise ValueError("For a disk, 'center' must be a tuple and 'radius' must be a number.") if not isinstance(material, str): raise ValueError("'material' must be a string.") self.shapes[name] = { "type": cst.ShapeTypes.disk.name, "material": material, "object": Point(center).buffer(radius, quad_segs=cst.DISK_QUAD_SEGS), } elif shape_type == cst.ShapeTypes.rectangle.name: min_x = kwargs.get("min_x") min_y = kwargs.get("min_y") max_x = kwargs.get("max_x") max_y = kwargs.get("max_y") if not all(isinstance(coord, (int, float)) for coord in [min_x, min_y, max_x, max_y]): raise ValueError("For a rectangle, 'min_x', 'min_y', 'max_x', and 'max_y' must be numbers.") self.shapes[name] = { "type": cst.ShapeTypes.rectangle.name, "material": material, "object": Polygon([(min_x, min_y), (min_x, max_y), (max_x, max_y), (max_x, min_y)]), } elif shape_type == cst.ShapeTypes.polygon.name: points = kwargs.get("points") if not isinstance(points, list) or not all(isinstance(point, tuple) for point in points): raise ValueError("For a polygon, 'points' must be a list of tuples.") if len(points) < 3 or points[0] != points[-1]: raise ValueError("A polygon must have at least 3 points and the first/last points must match.") self.shapes[name] = { "type": cst.ShapeTypes.polygon.name, "material": material, "object": Polygon(points), } else: raise ValueError(f"Unsupported shape type: {shape_type}. Must be one of {cst.ShapeTypes.__members__}.")
[docs] def get_additional_parameters(self) -> ShapeDataType: """ Retrieve the parameters for each stored shape. Returns ------- ShapeDataType A dictionary where each key is the name of a shape, and the corresponding value is a dictionary containing the parameters for that shape. The structure of the parameter dictionary depends on the type of shape: - **Disk**: - `type` (str): The type of the shape (always `'disk'`). - `radius` (float): The radius of the disk. - `material` (str): The material of the disk's interior. - `x` (float): The x-coordinate of the disk's center. - `y` (float): The y-coordinate of the disk's center. - **Rectangle**: - `type` (str): The type of the shape (always `'rectangle'`). - `material` (str): The material of the rectangle's interior. - `min_x` (float): The x-coordinate of the rectangle's minimum bound. - `min_y` (float): The y-coordinate of the rectangle's minimum bound. - `max_x` (float): The x-coordinate of the rectangle's maximum bound. - `max_y` (float): The y-coordinate of the rectangle's maximum bound. - **Polygon**: - `type` (str): The type of the shape (always `'polygon'`). - `material` (str): The material of the polygon's interior. - `points` (list of tuple[float, float]): A list of `(x, y)` coordinates representing the vertices of the polygon. Notes ----- This method assumes that all shapes are stored with their respective parameters in a consistent format. """ # Create a dictionary to store the parameters of each shape params: ShapeDataType = {} for name, shape in self.shapes.items(): material = shape.get("material") # Retrieve the parameters of each shape according to its type if shape["type"] == cst.ShapeTypes.disk.name: disk: Polygon = shape["object"] disk_center = disk.centroid disk_radius = disk.exterior.distance(disk.centroid) params[name] = { "type": cst.ShapeTypes.disk.name, "radius": float(np.round(disk_radius * cst.CM_TO_M, 3)), "material": material, "x": float(np.round(disk_center.x * cst.CM_TO_M, 3)), "y": float(np.round(disk_center.y * cst.CM_TO_M, 3)), } elif shape["type"] == cst.ShapeTypes.rectangle.name: rect: Polygon = shape["object"] min_x, min_y, max_x, max_y = rect.bounds params[name] = { "type": cst.ShapeTypes.rectangle.name, "material": material, "min_x": float(np.round(min_x * cst.CM_TO_M, 3)), "min_y": float(np.round(min_y * cst.CM_TO_M, 3)), "max_x": float(np.round(max_x * cst.CM_TO_M, 3)), "max_y": float(np.round(max_y * cst.CM_TO_M, 3)), } elif shape["type"] == cst.ShapeTypes.polygon.name: poly: Polygon = shape["object"] poly_points = list(poly.exterior.coords) poly_points = [ (float(np.round(point[0] * cst.CM_TO_M, 3)), float(np.round(point[1] * cst.CM_TO_M, 3))) for point in poly_points ] params[name] = { "type": cst.ShapeTypes.polygon.name, "material": material, "points": poly_points, } return params
[docs] def number_of_shapes(self) -> int: """ Return the total number of stored shapes. Returns ------- int The total number of shapes stored in the `shapes` attribute. """ return len(self.shapes)
[docs] def create_pedestrian_shapes(self, measurements: AgentMeasures) -> None: """ Create the shapes of a pedestrian based on the provided measures. This method generates the shapes of a pedestrian agent by scaling initial disk centers and radii according to the provided measurements. To find the correct scalings, it uses an optimization algorithm to minimize the difference between the desired and actual chest depth and bideltoid breadth. Parameters ---------- measurements : AgentMeasures An object containing the measurements of the pedestrian agent. Raises ------ ValueError If the agent type is not 'pedestrian'. """ # Validate the agent type if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("create_pedestrian_shapes() can only create pedestrian agents.") # Scale the initial pedestrian shapes to match the provided measurements sex_name = measurements.measures[cst.PedestrianParts.sex.name] homothety_center = Point(0.0, 0.0) if isinstance(sex_name, str) and sex_name in ["male", "female"]: initial_pedestrian = InitialPedestrian(sex_name) homothety_center = initial_pedestrian.get_position() def objectif_fun(scaling_factor: NDArray[np.float64]) -> float: """ Objective function to minimize the difference between the desired and actual pedestrian dimensions. Parameters ---------- scaling_factor : NDArray[np.float64] A 1D numpy array of length 2 containing scaling factors: - scaling_factor[0]: x-axis scaling factor - scaling_factor[1]: y-axis scaling factor Returns ------- float The penalty value representing the sum of squared differences between the desired and actual dimensions. """ # Retrieve the wanted measurements from the provided measures wanted_chest_depth = measurements.measures[cst.PedestrianParts.chest_depth.name] wanted_bideltoid_breadth = measurements.measures[cst.PedestrianParts.bideltoid_breadth.name] # Compute the new measurements based on the scaling factors scale_factor_x, scale_factor_y = scaling_factor adjusted_centers = [ scale(disk_center, xfact=scale_factor_x, origin=homothety_center) for disk_center in initial_pedestrian.get_disk_centers() ] adjusted_radii = [disk_radius * scale_factor_y for disk_radius in initial_pedestrian.get_disk_radii()] current_chest_depth = 2.0 * adjusted_radii[2] current_bideltoid_breadth = 2.0 * adjusted_centers[4].x + 2.0 * adjusted_radii[4] # Compute the penalty based on the difference between the new and old measurements penalty_chest = (current_chest_depth - wanted_chest_depth) ** 2 penalty_shoulder_breadth = (current_bideltoid_breadth - wanted_bideltoid_breadth) ** 2 return float(penalty_chest + penalty_shoulder_breadth) # Optimize the scaling factors to minimize the penalty bounds = np.array([[1e-5, 3.0], [1e-5, 3.0]]) guess_parameters = np.array([0.9, 0.9]) optimized_scaling = dual_annealing( objectif_fun, bounds=bounds, maxfun=cst.NB_FUNCTION_EVALS, x0=guess_parameters, ) optimized_scale_factor_x, optimized_scale_factor_y = optimized_scaling.x # Adjust the initial pedestrian shapes based on the optimized scaling factors adjusted_centers = [ scale(disk_center, xfact=optimized_scale_factor_x, origin=homothety_center) for disk_center in initial_pedestrian.get_disk_centers() ] adjusted_radii = [disk_radius * optimized_scale_factor_y for disk_radius in initial_pedestrian.get_disk_radii()] # Create the adjusted shapes for the pedestrian disks = [{"center": center, "radius": radius} for center, radius in zip(adjusted_centers, adjusted_radii, strict=False)] adjusted_shapes = { f"disk{i}": { "type": cst.ShapeTypes.disk.name, "material": cst.MaterialNames.human_naked.name, "object": Point(disk["center"]).buffer(disk["radius"], quad_segs=cst.DISK_QUAD_SEGS), } for i, disk in enumerate(disks) } self.shapes = adjusted_shapes
[docs] def create_bike_shapes(self, measurements: AgentMeasures) -> None: """ Create and scale 2D shapes for a bike and its rider based on provided measurements. This method uses an optimization process to generate bike and rider shapes that best match the provided measurements. It scales initial shapes to minimize the difference between desired and actual dimensions. Parameters ---------- measurements : AgentMeasures An object containing the target measurements for various bike parts and rider dimensions. Raises ------ ValueError If the agent type is not 'bike'. """ # Validate the agent type if self.agent_type != cst.AgentTypes.bike: raise ValueError("create_bike_shapes() can only create bike agents.") # Scale the initial bike shapes to match the provided measurements init_bike = InitialBike() def objective_fun(scaling_factor: NDArray[np.float64]) -> float: """ Objective function to minimize the difference between the desired and actual bike/rider dimensions. Parameters ---------- scaling_factor : NDArray[np.float64] An array containing the scaling factors for the bike and rider dimensions in the order [scale_bike_factor_x, scale_bike_factor_y, scale_rider_factor_x, scale_rider_factor_y]. Returns ------- float The penalty value representing the sum of squared differences between the desired and actual dimensions. """ # Unpack the scaling factors ( scale_bike_factor_x, scale_bike_factor_y, scale_rider_factor_x, scale_rider_factor_y, ) = scaling_factor # Retrieve the wanted measurements from the provided measures wanted_rider_width = measurements.measures[cst.BikeParts.handlebar_length.name] wanted_rider_length = measurements.measures[cst.BikeParts.top_tube_length.name] wanted_bike_width = measurements.measures[cst.BikeParts.wheel_width.name] wanted_bike_length = measurements.measures[cst.BikeParts.total_length.name] # Compute the new measurements based on the scaling factors new_shapes = { "bike": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.concrete.name, "min_x": init_bike.shapes2D["bike"]["min_x"] * scale_bike_factor_x, "min_y": init_bike.shapes2D["bike"]["min_y"] * scale_bike_factor_y, "max_x": init_bike.shapes2D["bike"]["max_x"] * scale_bike_factor_x, "max_y": init_bike.shapes2D["bike"]["max_y"] * scale_bike_factor_y, }, "rider": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.human_clothes.name, "min_x": init_bike.shapes2D["rider"]["min_x"] * scale_rider_factor_x, "min_y": init_bike.shapes2D["rider"]["min_y"] * scale_rider_factor_y, "max_x": init_bike.shapes2D["rider"]["max_x"] * scale_rider_factor_x, "max_y": init_bike.shapes2D["rider"]["max_y"] * scale_rider_factor_y, }, } current_bike_length = abs(new_shapes["bike"]["max_y"] - new_shapes["bike"]["min_y"]) current_rider_width = abs(new_shapes["rider"]["max_x"] - new_shapes["rider"]["min_x"]) current_rider_length = abs(new_shapes["rider"]["max_y"] - new_shapes["rider"]["min_y"]) current_bike_width = abs(new_shapes["bike"]["max_x"] - new_shapes["bike"]["min_x"]) # Compute the penalty based on the difference between the current and wanted measurements penalty_rider_width = (wanted_rider_width - current_rider_width) ** 2 penalty_rider_length = (wanted_rider_length - current_rider_length) ** 2 penalty_bike_width = (wanted_bike_width - current_bike_width) ** 2 penalty_bike_length = (wanted_bike_length - current_bike_length) ** 2 return float(penalty_rider_length + penalty_bike_width + penalty_bike_length + penalty_rider_width) # Optimize the scaling factors to minimize the penalty bounds = np.array([[1e-5, 3.0], [1e-5, 3.0], [1e-5, 3.0], [1e-5, 3.0]]) guess_parameters = np.array([0.99, 0.99, 0.99, 0.99]) optimised_scaling = dual_annealing( objective_fun, bounds=bounds, maxfun=cst.NB_FUNCTION_EVALS, x0=guess_parameters, ) opt_bike_sfx, opt_bike_sfy, opt_rider_sfx, opt_rider_sfy = optimised_scaling.x # optimised scaling factors # Adjust the initial bike shapes based on the optimized scaling factors adjusted_shapes = { "bike": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.concrete.name, "object": Polygon( [ ( init_bike.shapes2D["bike"]["min_x"] * opt_bike_sfx, init_bike.shapes2D["bike"]["min_y"] * opt_bike_sfy, ), ( init_bike.shapes2D["bike"]["min_x"] * opt_bike_sfx, init_bike.shapes2D["bike"]["max_y"] * opt_bike_sfy, ), ( init_bike.shapes2D["bike"]["max_x"] * opt_bike_sfx, init_bike.shapes2D["bike"]["max_y"] * opt_bike_sfy, ), ( init_bike.shapes2D["bike"]["max_x"] * opt_bike_sfx, init_bike.shapes2D["bike"]["min_y"] * opt_bike_sfy, ), ] ), }, "rider": { "type": cst.ShapeTypes.rectangle.name, "material": cst.MaterialNames.human_clothes.name, "object": Polygon( [ ( init_bike.shapes2D["rider"]["min_x"] * opt_rider_sfx, init_bike.shapes2D["rider"]["min_y"] * opt_rider_sfy, ), ( init_bike.shapes2D["rider"]["min_x"] * opt_rider_sfx, init_bike.shapes2D["rider"]["max_y"] * opt_rider_sfy, ), ( init_bike.shapes2D["rider"]["max_x"] * opt_rider_sfx, init_bike.shapes2D["rider"]["max_y"] * opt_rider_sfy, ), ( init_bike.shapes2D["rider"]["max_x"] * opt_rider_sfx, init_bike.shapes2D["rider"]["min_y"] * opt_rider_sfy, ), ] ), }, } self.shapes = adjusted_shapes
[docs] def get_geometric_shapes(self) -> list[Polygon]: """ Return the geometric shapes that constitute a pedestrian physical shape. Returns ------- list[Polygon] A list of Polygon objects representing the individual shapes. """ return [shape["object"] for shape in self.shapes.values()]
[docs] def get_geometric_shape(self) -> Polygon | MultiPolygon: """ Return the geometric union of all shapes that constitute a pedestrian physical shape. Returns ------- Polygon The union of all stored shapes as a single Polygon object. """ return unary_union(self.get_geometric_shapes())
[docs] def get_area(self) -> float: """ Compute the area of the agent 2D representation. Returns ------- float The area of the agent 2D representation (cm²). """ return float(self.get_geometric_shape().area)
[docs] def get_chest_depth(self) -> float: """ Compute the chest depth (anterior-posterior diameter) of a pedestrian agent in centimeters. The chest depth is defined as twice the radius of 'disk2', converted from meters to centimeters. Returns ------- float The chest depth of the agent in centimeters. Raises ------ ValueError If the agent is not a pedestrian or required parameters are missing. """ if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("get_chest_depth() can only be used for pedestrian agents.") parameters_shapes = self.get_additional_parameters() # Ensure 'disk2' and 'radius' are present if "disk2" not in parameters_shapes: raise ValueError("Missing required parameter: 'disk2' in agent shape parameters.") if "radius" not in parameters_shapes["disk2"]: raise ValueError("Missing 'radius' in 'disk2' parameters.") radius_m = parameters_shapes["disk2"]["radius"] chest_depth_cm = 2.0 * radius_m * cst.M_TO_CM return float(chest_depth_cm)
[docs] def get_bideltoid_breadth(self) -> float: """ Compute the bideltoid breadth (shoulder width) of a pedestrian agent in centimeters. Returns ------- float The bideltoid breadth of the agent in centimeters. Raises ------ ValueError If the agent is not a pedestrian or required parameters are missing. """ if self.agent_type != cst.AgentTypes.pedestrian: raise ValueError("get_bideltoid_breadth() can only be used for pedestrian agents.") parameters_shapes = self.get_additional_parameters() # Ensure required disks are present for disk in ("disk0", "disk4"): if disk not in parameters_shapes: raise ValueError(f"Missing required parameter: '{disk}' in agent shape parameters.") disk0 = parameters_shapes["disk0"] disk4 = parameters_shapes["disk4"] # Ensure required keys are present in each disk for key in ("x", "y", "radius"): if key not in disk0 or key not in disk4: raise ValueError(f"Missing '{key}' in disk parameters.") # Calculate the center-to-center distance between disk0 and disk4 dx = disk4["x"] - disk0["x"] dy = disk4["y"] - disk0["y"] center_distance = np.hypot(dx, dy) # Total breadth is the sum of both radii and the center distance, converted to cm total_breadth_m = disk0["radius"] + center_distance + disk4["radius"] bideltoid_breadth_cm = total_breadth_m * cst.M_TO_CM return float(bideltoid_breadth_cm)