"""Class to store body shapes dynamically 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
import numpy as np
from numpy.typing import NDArray
from scipy.optimize import dual_annealing
from shapely.affinity import scale
from shapely.geometry import MultiPoint, MultiPolygon
import configuration.utils.constants as cst
import configuration.utils.functions as fun
from configuration.models.initial_agents import InitialPedestrian
from configuration.models.measures import AgentMeasures
from configuration.utils.typing_custom import ShapeDataType
[docs]
@dataclass
class Shapes3D:
"""Store and manage 3D body shapes for different agent types."""
agent_type: cst.AgentTypes
shapes: ShapeDataType = field(default_factory=dict)
def __post_init__(self) -> None:
"""
Validate dataclass attributes after initialization.
Raises
------
ValueError
If any of the following validation checks fail:
- Agent type is not a member of `AgentTypes`
- Shapes container is not a dictionary
- Shape values are not Shapely MultiPolygon objects
- Height keys cannot be converted to float values
"""
# 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 height, multipolygon in self.shapes.items():
if not isinstance(multipolygon, MultiPolygon):
raise ValueError(f"Invalid shape type for '{height}': {type(multipolygon)}")
try:
float(height)
except ValueError:
raise ValueError(f"Invalid height type for '{height}': {type(height)}") from None
[docs]
def create_pedestrian3D(self, measurements: AgentMeasures) -> None:
"""
Create a 3D representation of a pedestrian based on provided measurements.
Parameters
----------
measurements : AgentMeasures
An object containing the target measurements of the pedestrian, including sex, bideltoid breadth, chest depth, and height.
Raises
------
ValueError
If the provided sex in "measurements" is not "male" or "female".
Notes
-----
- The method uses an initial pedestrian representation based on the provided sex.
- Scaling factors are calculated for each dimension (x, y, z) based on
the ratio of target measurements to initial measurements.
"""
# Extract sex from measurements and create initial pedestrian object
sex_name = measurements.measures[cst.PedestrianParts.sex.name]
if isinstance(sex_name, str) and sex_name in ["male", "female"]:
initial_pedestrian = InitialPedestrian(sex_name)
else:
raise ValueError(f"Invalid sex name: {sex_name}. Expected 'male' or 'female'.")
# Calculate scaling factors for each dimension
scale_factor_x = float(measurements.measures[cst.PedestrianParts.bideltoid_breadth.name]) / float(
initial_pedestrian.measures[cst.PedestrianParts.bideltoid_breadth.name]
)
scale_factor_y = float(measurements.measures[cst.PedestrianParts.chest_depth.name]) / float(
initial_pedestrian.measures[cst.PedestrianParts.chest_depth.name]
)
scale_factor_z = float(measurements.measures[cst.PedestrianParts.height.name]) / float(
initial_pedestrian.measures[cst.PedestrianParts.height.name]
)
reference_multipolygon = initial_pedestrian.get_reference_multipolygon()
wanted_chest_depth = float(measurements.measures[cst.PedestrianParts.chest_depth.name])
wanted_bideltoid_breadth = float(measurements.measures[cst.PedestrianParts.bideltoid_breadth.name])
def objectif_fun(scaling_factor: NDArray[np.float64]) -> float:
"""
Objective function to minimize the difference between the scaled bideltoid breadth and the target value.
Parameters
----------
scaling_factor : NDArray[np.float64]
The scaling factor for the x, y, and z dimensions.
Returns
-------
float
The absolute difference between the scaled bideltoid breadth and the target value.
"""
# Extract scaling factors for x, y, and z dimensions
scale_factor_x = scaling_factor[0]
scale_factor_y = scaling_factor[1]
homothety_center = reference_multipolygon.centroid
scaled_multipolygon = scale(
reference_multipolygon,
xfact=scale_factor_x,
yfact=scale_factor_y,
origin=homothety_center,
)
# Compute the scaled bideltoid breadth
scaled_bideltoid_breadth = fun.compute_bideltoid_breadth_from_multipolygon(scaled_multipolygon)
scaled_chest_depth = fun.compute_chest_depth_from_multipolygon(scaled_multipolygon)
penalty_chest: float = (scaled_chest_depth - wanted_chest_depth) ** 2
penalty_bideltoid: float = (scaled_bideltoid_breadth - wanted_bideltoid_breadth) ** 2
return float(penalty_chest + penalty_bideltoid)
# Optimize the scaling factors to minimize the penalty
bounds = np.array([[1e-5, 3.0], [1e-5, 3.0]])
guess_parameters = np.array([scale_factor_x, scale_factor_y])
optimized_scaling = dual_annealing(objectif_fun, bounds=bounds, x0=guess_parameters, maxfun=cst.NB_FUNCTION_EVALS)
optimized_scale_factor_x, optimized_scale_factor_y = optimized_scaling.x
# Initialize dictionary to store scaled 3D shapes
current_body3D: ShapeDataType = {}
# Calculate the center point for scaling (centroid of all initial shapes)
homothety_center = MultiPoint([multipolygon.centroid for multipolygon in initial_pedestrian.shapes3D.values()]).centroid
# Scale each component of the initial 3D representation
for height, multipolygon in initial_pedestrian.shapes3D.items():
scaled_multipolygon = scale(
multipolygon,
xfact=fun.rectangular_function(height, optimized_scale_factor_x, sex_name),
yfact=fun.rectangular_function(height, optimized_scale_factor_y, sex_name),
origin=homothety_center,
)
scaled_height = height * scale_factor_z
current_body3D[scaled_height] = MultiPolygon(scaled_multipolygon)
# Update the shapes attribute with the new 3D representation
self.shapes = current_body3D
[docs]
def get_height(self) -> float:
"""
Compute the height of the agent in meters.
Returns
-------
float
The height of the agent in meters.
"""
if self.agent_type != cst.AgentTypes.pedestrian:
raise ValueError("get_height() can only be used for pedestrian agents.")
shapes3D_dict = self.shapes
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]
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.shapes.keys())
largest_height = max(self.shapes.keys()) - smallest_height
theoretical_torso_height = largest_height * cst.HEIGHT_OF_BIDELTOID_OVER_HEIGHT + smallest_height
closest_height = min(self.shapes.keys(), key=lambda x: abs(float(x) - theoretical_torso_height))
multip = self.shapes[closest_height]
return multip
[docs]
def get_bideltoid_breadth(self) -> float:
"""
Compute the bideltoid breadth of the agent (that has an orientation of 90°) in cm.
Returns
-------
float
The bideltoid breadth of the agent in 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 an orientation of 90°) in cm.
Returns
-------
float
The chest depth of the agent in 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))