task_generator human simulator¶
BaseHumanSimulator manages the pedestrian lifecycle (spawn, move, remove)
and the per-episode obstacle bookkeeping layer. Implementations are registered
in HumanSimulatorRegistry. See also sim interface for the
physics-simulator counterpart (BaseSim).
BaseHumanSimulator¶
class BaseHumanSimulator(NodeInterface, abc.ABC):
Holds a KnownObstacles table that tracks every spawned obstacle by name,
its spawned flag, and its ObstacleLayer. On __init__ it subscribes to
<namespace>/arena_peds, caches ped positions in _ped_positions_xy, and
calls self._simulator.attach_human_simulator(self) so the mechanism shim
(MechanismITF, see sim interface)
can read ground-truth ped positions and dispatch ped teleports through this
class. Public methods delegate to both the physics BaseSim and the
human-sim _*_impl methods:
| Public method | Purpose |
|---|---|
spawn_obstacles(obstacles, layer) |
spawn or move static obstacles, layer defaults to INUSE |
spawn_dynamic_obstacles(obstacles) |
spawn or move dynamic obstacles (INUSE) |
spawn_world(walls, doors, collision_walls=()) |
spawn world geometry in both sim and human-sim layers. collision_walls register in the human-sim layer only (avoidance), never spawned visually |
unuse_obstacles() |
call _remove_obstacles_impl, then flip all INUSE layers to UNUSED |
remove_obstacles(purge) |
remove all obstacles at or below purge layer from both layers. WORLD survives unless purge >= WORLD |
spawn_robot(robots) |
spawn in physics sim, start tracking the robot, then call _spawn_robot_impl |
remove_robot(robots) |
remove from physics sim, stop tracking, then call _remove_robot_impl |
move_robot(robots) |
move in physics sim, set the tracked pose at rest, then call _move_robot_impl |
tracked_robots() |
every spawned robot as a TrackedRobot: map-frame pose from the map to base-frame TF and the velocity between its last two TF stamps |
notify_stimulus(agent_id, stimulus, intensity) |
stimulus seam, no-op by default, fed edge-triggered by the acoustics backend's PedestrianHearing (arena: continuous_heard_sounds for agent:<id> listeners) |
HumanSimulator Protocol surface¶
BaseHumanSimulator satisfies the Protocol the mechanism shim reads from.
Inherited defaults work for all current subclasses, override only for
specialized teleport semantics (e.g. resetting an internal agent list).
| Method | Signature |
|---|---|
pedestrian_discs() |
() -> Iterable[tuple[str, tuple[float, float], float]] (sync, reads _ped_positions_xy, radius is PED_RADIUS) |
pedestrian_teleport(destinations) |
(Mapping[str, tuple[float, float]]) -> bool (async, dispatches via relay_pedestrian_update) |
configure(params) |
(Sequence[rcl_interfaces.msg.Parameter]) -> list[Parameter] (async). It runs at every episode reset with QueueEpisode.human_params. It strips the prefix from the names under PARAM_NAMESPACE, sorts them, and hands them to _configure_impl, also when there are none. It returns the accepted ones. |
Abstract _impl methods¶
Every subclass must implement:
| Method | Purpose |
|---|---|
_spawn_obstacles_impl(obstacles) |
human-sim side: register or prepare static obstacles |
_spawn_dynamic_obstacles_impl(obstacles) |
human-sim side: register pedestrian agents |
_remove_obstacles_impl() |
human-sim side: signal removal of current episode's obstacles |
_spawn_walls_impl(walls) |
human-sim side: ingest wall geometry |
_spawn_doors_impl(doors) |
human-sim side: ingest door geometry |
_spawn_robot_impl(robots) |
human-sim side: register robots |
_remove_robot_impl(robots) |
human-sim side: deregister robots |
_move_robot_impl(robots) |
human-sim side: update robot positions |
GaitGenerator as articulation ground truth¶
BaseHumanSimulator.publish_arena_peds is the single point where skeletal
joint angles are committed to the arena_peds bus. For each Pedestrian in
the outgoing message it checks ped.joint_state.name:
- non-empty: upstream backend supplied its own joint state, published unchanged (override path: an upstream producer that already computes joint angles).
- empty:
publish_arena_pedscallsAnimationManager.compute(GaitGeneratorfor idle/walk/run, clip playback and overlays for everything else) and fills the field with bare semantic joint names (40 DOF, no body suffix).
The filled field feeds the ROS4HRI skeleton in rviz through hri_producer and the Isaac and Gazebo pedestrian rigs.
A model whose bundle ships a rig.yaml with drivers: (wheelchair bundles: l_wheel, r_wheel) gets those joints
set on top, whoever filled the field: drivers.py rolls each wheel over the distance its contact
point covered since the last message, see the Driven joints section of JOINTS.md.
Fitting a gait profile from a clip¶
The walk and run poses GaitGenerator emits come from the gait profiles in profiles/
(profile.py: a mean plus harmonics per joint, read at the gait phase). fit.py turns a
walking clip into such a profile:
python3 -m task_generator.simulators.human.fit walk_slow.npy -o walk_slow.yaml --speed 0.8 --report walk_slow.json
The clip is a .npy in the layout of animations/ (an object array of frames, each
{"angles": {<joint>: <rad>}} with bare JOINT_NAMES) holding at least two whole cycles of steady walking. The
fitter takes the cycle from the sagittal hips (l_r_hip, r_r_hip), sets phase 0 so the left hip lines up with
walk_cmu_12_01, and fits every joint over whole cycles. It writes one signal per limb pair when the right side is
the left half a cycle later (within 5 percent of the pair's amplitude), per-side signals with symmetric: false
otherwise, a phase_warp.split when the two half-cycles differ in length by more than 0.02, and limits for
joints the clip takes past GaitGenerator.LIMITS. The printed report lists the rms error per joint, the cycles
used, the measured cadence, the split and the worst joint.
| Option | Meaning |
|---|---|
-o PROFILE.yaml |
Output profile. Move it into profiles/, the library goes by file stem, then name it from an agent type as pose: {walk: {base: <stem>}}. |
--fps F |
Frame rate of the clip, default 20 (the rate AnimationManager plays the shipped clips at). |
--harmonics N |
Harmonics per joint, 1 to 8, default 3. |
--speed V |
Walking speed of the clip in m/s: the cadence law keeps the default base and takes per_speed through the measured point. Without it the law is fixed at the measured cadence. |
--report REPORT.json |
Also write the report as json. |
--name NAME |
name: field of the profile, default the output stem. |
Gestures¶
Pedestrian.gestures (arena_people_msgs/Gesture[]: slot in head|arm|arm_l|arm_r|body, at world-frame
point for the aimed slots, clip name on body, hand on arm) are attention channels, not poses. Backends
only forward them (arena_humansim copies AgentGestures per owner one to one, the possession stream carries them
as-is), and publish_arena_peds hands them per ped to the GestureLayer as a
GestureRequest (a Channel(slot, at, clip, hand) per entry, ped pose, moving flag). The layer resolves each
aimed target into the ped frame once per clip, asks the slot's generator for frames (head -> look,
arm* -> point, body -> clip, the cached .npy named on the wire over the joints
annotations.yaml lists for it), and plays them as independent AnimationManager
overlay slots (head, arm, body) over the locomotion base. Nothing is synthesized: no channel published =
that body part idle. See gestures/README.md for slots, hand resolution,
timing, and how to add a kind.
An empty list releases every slot, a missing entry releases that slot. Unknown slots warn once per ped and are ignored. Clips come from the baked pointing table and are generated inline on the publishing tick, so their timing is sim-deterministic under lockstep without any extra plumbing.
Visualization topics¶
Pedestrian visualization flows through one data feed plus a few marker layers, all at
env level (<env_ns>/) and shared by every backend:
| Topic | Producer | QoS | Role |
|---|---|---|---|
arena_peds |
each backend (publish_arena_peds) |
reliable, volatile | Pedestrian state feed (positions/velocities/joint_state). joint_state carries bare semantic joint names filled by GaitGenerator unless the backend overrides it (non-empty = upstream wins). |
humans/bodies/tracked, humans/persons/*, humans/bodies/<id>/joint_states |
hri_producer node |
per REP-155 | Canonical ROS4HRI projection of arena_peds: id lists, per-person engagement, per-body joint states, per-body URDF on param human_description_<id>. |
humans/tf |
hri_producer node and its robot_state_publisher pool |
reliable, volatile | TF body_<id> and the joint frames, kept off the env /tf and published only while subscribed. |
pedestrian_markers/extra |
base class (publish_markers) |
best-effort, volatile | Backend-internal debug overlay (e.g. arena_humansim forwards its module markers and retargets arena_humansim_viz_node here with the env offset). Off by default. The arena_humansim adapter subscribes to the engine's viz only while this topic has a subscriber, so an unwatched env costs the engine no marker work. |
pedestrian_markers/static |
base class (publish_static_markers) |
reliable, transient-local, depth 1 | Latched static scene as one combined topic. |
pedestrian_markers/static_* |
adapter | reliable, transient-local, depth 1 | Latched static scene split per bucket (/static_walls, /static_objects, ...). |
Rendering contract. The canonical human view is an animated articulated skeleton, produced by the
hri_producer node, which subscribes
arena_peds and projects it into the ROS4HRI (REP-155) humans/ namespace: id lists, per-person
engagement, and a per-body robot_state_publisher (pooled) driven from humans/bodies/<id>/joint_states
against the human_description URDF rig. The hri_rviz/Skeletons3D
display renders one kinematic model per body.
hri_producer is a relay: it re-suffixes joint names from arena_peds.joint_state per body ID and
publishes them directly. The producer's own GaitGenerator instance is a fallback only for peds whose
joint_state arrives empty (backends that do not fill joint_state on the bus).
extra is backend debug, disabled by default.
Display kinds (arena_viz.DisplayKind):
- PEDESTRIANS: the canonical hri_rviz/Skeletons3D skeleton display, keyed on the env humans/
namespace. Note: the upstream display uses absolute /humans paths via libhri, so per-env namespacing
is a known limitation.
- MARKER_ARRAY: generic MarkerArray passthrough, no namespace assumptions, used for extra and all
static* layers.
The auto-rviz manifest (node.py _publish_viz_manifest) groups these into a
Pedestrians folder (skeleton display + extra, off) and a separate Static folder (static,
static_walls, static_objects). The pedestrian_markers/ prefix on the debug/static layers is
historical, they are overlays, not pedestrian data.
Current adapters:
| Adapter | Static topics published |
|---|---|
arena_humansim |
pedestrian_markers/static_walls, pedestrian_markers/static_objects |
dummy |
pedestrian_markers/static_walls, pedestrian_markers/static_objects |
PROMPT registration¶
TM_Prompt is not registered centrally. Each BaseHumanSimulator subclass
that supports LLM-driven obstacle generation registers its own TM_Obstacles
variant (including the system-prompt text and response parser) via
_register_task_modes at class-definition time. This co-locates the prompt
with the simulator that will animate the resulting agents.
Registered implementations¶
HumanSimulatorRegistry (__init__.py) maps
Constants.HumanSimulator keys to async factory functions. Per-sim defaults
(resolved in task_generator.launch.py): gazebo/isaac sims default to
human:=arena, the dummy sim (and bare/test contexts) to
human:=dummy.
| Key | Class | File | Notes |
|---|---|---|---|
dummy |
DummyHumanSimulator |
dummy.py |
no-op stubs, used in test/offline contexts |
none |
DummyHumanSimulator |
dummy.py |
human.launch.py starts no node, backed by the same no-op stubs for registry lookups |
arena |
ArenaHumanSimulator |
arena_humansim/arena_humansim.py |
integrates with the arena_humansim pedestrian simulator (subsystem mode) |
hunav |
HunavHumanSimulator |
hunav/hunav.py |
integrates with HuNav, see configs/hunav/README.md for the per-agent schema. hunav_msgs is imported lazily so the registry entry loads without it installed |
arena_humansim agent types¶
ArenaHumanSimulator derives pedestrian parameters from the agent: block of
each dynamic: scenario entry (ArenaHumanDynamicObstacle,
arena_humansim/__init__.py). agent: accepts
either a bare string or a dict:
dynamic:
- name: nurse_1
...
agent: adult # shorthand for {agent_type: adult}
- name: doctor_1
...
agent: {agent_type: ./doctor.yaml, desired_velocity: 1.4, radius: 0.32}
- name: source_ped
...
agent: {agent_type: adult, desired_velocity: {min: 1.0, max: 1.5}}
| Key | Meaning |
|---|---|
agent_type |
Built-in arena_humansim type name (adult, elder, robot, shipped under arena_humansim/config/agent_types/) or a scenario-local YAML path resolved relative to the scenario (./doctor.yaml). Default adult. See config/agent_types/README.md for the file schema. |
desired_velocity |
Either a scalar (m/s), or {min, max}: a uniform range this instance's velocity is drawn from. Overrides (does not compose with) the sampled AgentType.desired_velocity. Default {min: 1.0, max: 1.5}. |
radius |
Agent collision radius (m), overrides the sampled AgentType.agent_radius. Default 0.35. |
waypoint_mode is a sibling key of agent: (not nested inside it), one of repeat (default), reverse, once, random: it controls how the entry's waypoints: list is replayed once exhausted.
A regions: source entry's config.agent: block uses a different, wider schema for continuously spawning pedestrians: agent_type, desired_velocity: {min, max}, agent_radius (note: agent_radius, not radius, here), behavior_tree, and sink_affinity: [{sink, weight}]. See _add_source_region in arena_humansim/arena_humansim.py.
A scripted entry renders with its model:. A source-spawned agent has none, so it takes a model from its agent type: when the type lists assets: tags, one of the human bundles on disk (world-local, shared-local or already cached) whose annotation.yaml carries every tag is drawn per agent from the episode seed. A type without assets:, or tags no bundle carries (warned once per type), keeps the default model.
Static world objects¶
A static: scenario entry that pedestrians can interact with (a bench, a
reception desk) is registered as a WorldObject by _spawn_obstacles_impl in
arena_humansim/arena_humansim.py. The
keys below are written directly on the entry, as siblings of name:/model:/pose:
(they land on Obstacle.extra, a copy of the entry's full raw dict):
| Key | Meaning |
|---|---|
type |
Object type string, matched against a behavior-tree step/action target: (falls back to the model's annotation.yaml name/desc if omitted). |
capacity |
Max simultaneous occupants. Default 1. |
satisfies |
{need: amount} applied to an agent that completes an interaction here, same shape as an agent-type step's satisfies:. |
interaction_radius |
Overrides the interaction kind's default approach radius for this object. |
formation: {type, params} |
Provider-side formation for agents interacting here (type one of line, cluster, f_formation, dyad, see config/agent_types/README.md). |
static:
- name: reception_desk
...
type: desk
capacity: 3
interaction_radius: 1.0
formation: {type: line, params: {base_step: 0.8, front_offset: 0.6}}
Possession¶
Every backend supports upstream override through the possession layer in
BaseHumanSimulator, with the pure logic in
possession.py.
human/stream (Pedestrians, env-level, reliable/volatile depth 1) carries
full pedestrian state, and each batch is the publisher's complete claim set:
a validated entry claims or renews its ped (deep copy stored), a possessed
name absent from a valid batch is released instantly, and silence past
POSSESSION_TIMEOUT_S (1 s) releases everything as the crash fallback,
evaluated lazily on sim time with no timers. Batches are dropped while the
reset gate is closed and when their header stamp predates the gate-open
stamp (ped names persist across episodes). Unknown names and non-bare joint
names drop per entry (suffixing stays hri_producer's job), positions pass
through unclamped (gait.LIMITS is advisory and generator-side), model_uri
is ignored.
Enforcement is substitution at the two outbound funnels: publish_arena_peds
(the bus) and relay_pedestrian_update (the sim). Substitution is
copy-on-write: possessed entries swap to deep copies of the stored state,
everything else passes through untouched, and the caller's message is never
mutated (hunav hands a persistent container to both funnels). Substituted
entries get gait_phase re-stamped from the base's own GaitGenerator
phase table, engines never see the publisher's zero.
human/move (srv/MovePedestrians, env-level) is a teleport served by the
base, only name and pose are read. Known peds are moved in place (no
forget() round-trip, that would reset spawn bookkeeping) and relayed via
pedestrian_move, everything else returns NOT_FOUND per item. A move does
not claim possession.
The reset gate opens at the end of spawn_dynamic_obstacles (which also
indexes each ped under both its name and its sim_path, the bus name differs
per backend) and closes in unuse_obstacles and the remove_obstacles
dynamic purge, which clear the table without release hooks (those peds cease
to exist). Backends observe possession through synchronous no-op hooks
(_on_stream_merged, _on_ped_released, _on_ped_moved) and the
possessed_peds() accessor.
Dummy adapter (human:=dummy)¶
DummyHumanSimulator (dummy.py) has no locomotion engine,
the possession stream is the sole source of motion. It seeds a roster cache
at spawn (sim_path names, IDLE poses) and republishes it through
publish_arena_peds at 30 Hz of sim time, letting the possession
substitution overlay driven peds on the way out. A paused sim repeats no
roster, only a merge or a move publishes then.
Its hooks keep the cache honest: a merge publishes the full roster, a
release adopts the last driven pose with an empty joint_state so the
synthesized gait eases the ped back to idle, a move adopts the teleport pose
and publishes. Nothing moves on its own: without a publisher peds hold
position with idle gait, and closing the GUI freezes them, it does not
despawn them. Reset closes the gate and clears the cache, respawn reseeds it
at the spawn poses and reopens the gate. Walls and static obstacles have no
engine to live in, so they go out as latched
pedestrian_markers/static_walls / static_objects MarkerArrays (ns
dummy_walls / dummy_objects), each led by a DELETEALL so removals
clear stale markers.
Arena adapter (human:=arena)¶
ArenaHumanSimulator feeds possession back to the engine: a loop at the
possession stream rate (20 Hz) publishes every possessed ped and every
tracked robot on the engine's world_state topic, pose and world-frame
velocity both. Possessed peds that are known engine agents go out under
the engine's own agent id, so the engine keeps its internal copy in full
sync: pose slaved to the feed, velocity carried through so neighboring
agents anticipate the motion, autonomy suspended while the lease is
fresh, and the agent resumes from right there when possession ends. Ids
the engine does not recognize become transient external obstacles in its
force pool instead. Either way the crowd sees and avoids a possessed ped,
walking or parked.
Every tracked robot goes out twice under one agent id: under its own name
(jackal, jackal_1) and under robot_<i>, its index in spawn order. The
engine binds both names to the same agent, so an agent-type file reaches the
robot of any model through an attention ref such as point: robot:robot_0.
An index name that is already a robot's own name is left out. In a fleet
robot_0 is the first spawned robot, not the one nearest to the ped.
With human.robots.visible:=false the loop leaves every robot out of the feed
and publishes the possessed peds alone. The engine then holds no robot agent,
its pedestrians neither avoid a robot nor resolve a robot: attention ref.
PARAM_NAMESPACE is humansim, and a humansim.<param> launch arg sets the same engine param for the whole
run. At every episode reset _configure_impl sends the staged humansim.* names of
QueueEpisode.human_params to the engine node as one set_parameters_atomically batch and then calls the
engine's reset with soft: true. The batch applies as a whole or not at all, so an episode never runs on
half of a stage's human config. The reset call is the engine's episode boundary, where it applies the params,
moves the live agents onto them, and prunes unused planners. See
Runtime reconfiguration. The engine rejects a batch
that holds a param it cannot reconfigure. A rejected batch logs an error with the engine's reason and stays
out of EpisodeRecord.human_params, and the episode continues.
A new backend with episode-level params adds its namespace to Constants.HUMAN_PARAM_NAMESPACES, sets
PARAM_NAMESPACE from it, and implements _configure_impl. The human. prefix is reserved for params every
backend honors. None are defined, so queue_episode rejects it.
The engine runs in the world frame (authored coordinates, levels laid out, no env offset). The adapter owns the env offset on both sides: spawn poses, waypoints, walls, world objects, regions, robot and possessed poses are un-shifted going in, agent poses, gesture targets, flow agents and the engine's marker layers are shifted coming out. Nothing engine-side knows the env.
World objects go to the engine with what the model annotation and the scenario entry say: bounding_box,
hoi, capacity, satisfies, interaction_radius, formation, and seats (object-local {x, y} with an
optional yaw, the same grammar as pose, from the annotation or overridden per scenario entry, and an empty
list clears the annotation's). Seats become the cluster slots a sitter is placed on, so a SIT_ON parks the
ped on the seat, not on a ring around the object. An object without seats keeps the ring. A ped approaches
the object's origin, not its seat, so an object whose seats sit outside interaction_radius needs that radius
widened to cover the object or the ped never gets close enough to sit.
HuNavSim default agent template¶
HunavHumanSimulator derives pedestrian parameters from a HunavDynamicObstacle
instance. At module import time
(hunav/__init__.py:326), the class-level default is
loaded from:
arena_simulation_setup/configs/hunav/default.yaml
via _load_config(). This file sets the default behavior parameters
(behavior, desired_velocity, radius, behavior_tree, etc.) for every
pedestrian not otherwise configured. The path is resolved via
get_package_share_directory("arena_simulation_setup") at startup.
Adding a new BaseHumanSimulator¶
- Create
simulators/human/<name>.pywith a class extendingBaseHumanSimulator, implement all eight_*_implabstract methods. - Add
<NAME> = "<name>"toConstants.HumanSimulatorinconstants/__init__.py. - Register a lazy async factory in
simulators/human/__init__.py:
@HumanSimulatorRegistry.register(Constants.HumanSimulator.MY_SIM)
async def _my_sim(**kwargs):
from .my_sim import MyHumanSimulator
return await MyHumanSimulator.create(**kwargs)
If the implementation supports LLM-driven obstacle generation, call
_register_task_modes in the class body to register a TM_Obstacles
subclass (with prompt text) under Constants.TaskMode.TM_Obstacles.PROMPT.