Skip to content

Commit 035eb8a

Browse files
NitjsefnieKimi K3
andauthored
Add group formation & dispersal scene with per-frame membership GT (#74)
A new groups module builds a Scene whose N agents converge into a cluster, dwell, then disperse, and derives deterministic per-frame group-membership ground truth from the scene alone (within radius of the per-frame centroid for >= min_dwell_frames consecutive frames). The GT rides in an opt-in JSON sidecar mirroring the order.py precedent; the analytic manifest is untouched and byte-identical whether or not the GT is requested. A group_id field keeps future multi-group output additive. Refs #66 Co-authored-by: Kimi K3 <noreply@kimi.com>
1 parent 47ffa0c commit 035eb8a

3 files changed

Lines changed: 460 additions & 0 deletions

File tree

src/multicam_sim/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@
2121
from .cameras import Camera, Intrinsics
2222
from .dropout import SensorDropout
2323
from .entities import Entity, EntityFrame
24+
from .groups import (
25+
GroupFrameMembership,
26+
GroupMembership,
27+
build_group_formation_scene,
28+
compute_group_membership,
29+
write_group_json,
30+
)
2431
from .manifest import (
2532
AssumedCalibration,
2633
CameraManifest,
@@ -68,6 +75,8 @@
6875
"EntityFrame",
6976
"EntityManifest",
7077
"FrameObs",
78+
"GroupFrameMembership",
79+
"GroupMembership",
7180
"HandKeyframe",
7281
"HandOccluder",
7382
"Intrinsics",
@@ -88,17 +97,20 @@
8897
"TransitEdge",
8998
"YoloDataset",
9099
"YoloLabel",
100+
"build_group_formation_scene",
91101
"build_manifest",
92102
"build_mtmc_scene",
93103
"silhouette_visible_fraction",
94104
"build_multi_entity_scene",
95105
"build_pose_smoke_scene",
96106
"build_smoke_scene",
107+
"compute_group_membership",
97108
"export_coco",
98109
"export_overlay",
99110
"export_yolo",
100111
"validate_manifest",
101112
"write_coco",
113+
"write_group_json",
102114
"write_manifest",
103115
"write_yolo",
104116
]

src/multicam_sim/groups.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Group formation & dispersal: a scene builder + per-frame membership GT.
2+
3+
Occupancy / group-behaviour analysis needs a scene where N agents converge
4+
into a spatial cluster, dwell as a group, then disperse — plus ground truth
5+
recording *which entities belong to the group at every frame*.
6+
7+
Two pieces, both self-contained:
8+
9+
* :func:`build_group_formation_scene` — a :class:`~multicam_sim.scene.Scene`
10+
whose agents lerp from a wide ring onto a tight cluster ring (both centred
11+
on ``group_center``), hold, then lerp radially back out along directions
12+
rotated half a sector from the approach. The ring
13+
geometry is symmetric, so the per-frame centroid of all agents stays at
14+
``group_center`` throughout — the membership rule below is then exact.
15+
* :func:`compute_group_membership` — the deterministic membership rule, pure
16+
on the scene alone: an entity is a member at frame ``f`` iff its tracked
17+
point has been within ``radius`` of the per-frame group centroid for at
18+
least ``min_dwell_frames`` consecutive frames ending at ``f``. Formation
19+
and dispersal frames fall out of that rule; nothing is hand-annotated.
20+
21+
The ground truth is a SINGLE group (``group_id`` defaults to ``"group-0"``).
22+
The id field is carried so multi-group output later is an additive change —
23+
a list of these records — not a schema fork.
24+
25+
**Sidecar, opt-in.** Like the order GT (:mod:`multicam_sim.order`), the
26+
membership record rides in its own JSON sidecar via :func:`write_group_json`
27+
and never touches the analytic manifest: it is computed FROM the scene, not
28+
stored on it, so ``build_manifest`` output is byte-identical whether or not
29+
the GT is requested. Everything round-trips via pydantic ``model_dump``.
30+
31+
Scale follows the smoke scenes (:mod:`multicam_sim.smoke`): cameras on a
32+
radius-4 ring looking at ``[0, 0, 0.5]`` with 640x480 @ f=800, agent paths
33+
within roughly +-1 of the origin — so ``radius=0.5`` and ``cluster_spread=0.2``
34+
are scene-scale distances, not arbitrary constants.
35+
"""
36+
37+
from __future__ import annotations
38+
39+
import json
40+
import math
41+
from pathlib import Path
42+
from typing import Any
43+
44+
import numpy as np
45+
from pydantic import BaseModel, ConfigDict, field_validator
46+
47+
from .cameras import Camera, Intrinsics
48+
from .entities import Entity, EntityFrame
49+
from .geometry import FloatArray
50+
from .scene import Scene
51+
52+
# Camera rig — identical geometry to the smoke scenes so the group stays in
53+
# frame of every camera for the whole run.
54+
_CAM_RING_RADIUS = 4.0
55+
_CAM_HEIGHT = 1.5
56+
_FOCAL = 800.0
57+
_WIDTH = 640
58+
_HEIGHT_PX = 480
59+
60+
_DEFAULT_RADIUS = 0.5
61+
_DEFAULT_MIN_DWELL_FRAMES = 3
62+
63+
64+
class GroupFrameMembership(BaseModel):
65+
"""One frame of group ground truth: the group centroid and its member ids."""
66+
67+
model_config = ConfigDict(frozen=True)
68+
69+
frame: int
70+
centroid: tuple[float, float, float]
71+
members: list[str]
72+
73+
74+
class GroupMembership(BaseModel):
75+
"""Per-frame membership ground truth for one group, derived from a scene.
76+
77+
``formation_frame`` is the first frame with at least one member;
78+
``dispersal_frame`` is the first frame after formation with no members
79+
(``None`` when the group never forms or never disperses). Both fall out
80+
of the deterministic rule in :func:`compute_group_membership` — they are
81+
recorded here for convenience, not annotated by hand.
82+
"""
83+
84+
model_config = ConfigDict(frozen=True)
85+
86+
group_id: str = "group-0"
87+
radius: float
88+
min_dwell_frames: int
89+
point: str = "center"
90+
formation_frame: int | None
91+
dispersal_frame: int | None
92+
frames: list[GroupFrameMembership]
93+
94+
@field_validator("radius")
95+
@classmethod
96+
def _positive_radius(cls, value: float) -> float:
97+
if value <= 0:
98+
raise ValueError("group radius must be > 0")
99+
return value
100+
101+
@field_validator("min_dwell_frames")
102+
@classmethod
103+
def _positive_dwell(cls, value: int) -> int:
104+
if value < 1:
105+
raise ValueError("min_dwell_frames must be >= 1")
106+
return value
107+
108+
def to_json(self, *, indent: int | None = 2) -> str:
109+
"""Serialise to a JSON string (the ``groups.json`` sidecar payload)."""
110+
return self.model_dump_json(indent=indent)
111+
112+
113+
def build_group_formation_scene(
114+
*,
115+
num_agents: int = 4,
116+
num_frames: int = 60,
117+
fps: float = 30.0,
118+
group_center: tuple[float, float, float] = (0.0, 0.0, 0.5),
119+
radius: float = _DEFAULT_RADIUS,
120+
cluster_spread: float = 0.2,
121+
start_radius: float = 1.0,
122+
arrival_frame: int = 20,
123+
departure_frame: int = 40,
124+
) -> Scene:
125+
"""Build a scene where ``num_agents`` agents cluster, dwell, then disperse.
126+
127+
Agent ``i`` starts on a ring of ``start_radius`` around ``group_center``
128+
at angle ``2*pi*i/num_agents``, lerps to the same angle on a ring of
129+
``cluster_spread`` by ``arrival_frame``, holds until ``departure_frame``,
130+
then lerps radially back out to the wide ring along a direction rotated
131+
half a sector (so agents leave instead of retracing their approach). All
132+
agents share the timing, so their per-frame centroid stays at
133+
``group_center`` and every agent is inside ``radius`` of it for the whole
134+
dwell window
135+
(``cluster_spread`` must be < ``radius``). The cameras mirror the smoke
136+
rig (radius-4 ring aimed at ``group_center``), so all agents stay in view.
137+
138+
``radius`` is the same value the membership rule expects; pass it to
139+
:func:`compute_group_membership` to get matching ground truth.
140+
"""
141+
if num_agents < 2:
142+
raise ValueError("a group needs at least 2 agents")
143+
if not 0 < arrival_frame < departure_frame < num_frames:
144+
raise ValueError("need 0 < arrival_frame < departure_frame < num_frames")
145+
if not 0 < cluster_spread < radius <= start_radius:
146+
raise ValueError("need 0 < cluster_spread < radius <= start_radius")
147+
148+
center = np.asarray(group_center, dtype=np.float64)
149+
intrinsics = Intrinsics.from_focal(_FOCAL, _WIDTH, _HEIGHT_PX)
150+
cameras = []
151+
for i in range(3):
152+
angle = 2.0 * math.pi * i / 3.0
153+
eye = np.array(
154+
[_CAM_RING_RADIUS * math.cos(angle), _CAM_RING_RADIUS * math.sin(angle), _CAM_HEIGHT],
155+
dtype=np.float64,
156+
)
157+
cameras.append(Camera.look_at(i, intrinsics, eye, center))
158+
159+
entities = []
160+
for i in range(num_agents):
161+
theta = 2.0 * math.pi * i / num_agents
162+
direction = np.array([math.cos(theta), math.sin(theta), 0.0], dtype=np.float64)
163+
# Disperse radially outward along a direction rotated half a sector from
164+
# the approach, so agents leave the cluster instead of retracing it.
165+
exit_theta = theta + math.pi / num_agents
166+
exit_direction = np.array(
167+
[math.cos(exit_theta), math.sin(exit_theta), 0.0], dtype=np.float64
168+
)
169+
start = center + start_radius * direction
170+
cluster = center + cluster_spread * direction
171+
end = center + start_radius * exit_direction
172+
frames = [
173+
EntityFrame(
174+
frame=f,
175+
points={
176+
"center": _agent_at(
177+
f, start, cluster, end, arrival_frame, departure_frame, num_frames
178+
).tolist()
179+
},
180+
)
181+
for f in range(num_frames)
182+
]
183+
entities.append(Entity(id=f"agent-{i}", frames=frames))
184+
185+
return Scene(fps=fps, num_frames=num_frames, cameras=cameras, entities=entities)
186+
187+
188+
def _agent_at(
189+
frame: int,
190+
start: FloatArray,
191+
cluster: FloatArray,
192+
end: FloatArray,
193+
arrival_frame: int,
194+
departure_frame: int,
195+
num_frames: int,
196+
) -> FloatArray:
197+
"""Piecewise-linear path: approach, dwell at the cluster, disperse."""
198+
if frame <= arrival_frame:
199+
return start + (cluster - start) * (frame / arrival_frame)
200+
if frame <= departure_frame:
201+
return cluster
202+
frac = (frame - departure_frame) / (num_frames - 1 - departure_frame)
203+
return cluster + (end - cluster) * frac
204+
205+
206+
def compute_group_membership(
207+
scene: Scene,
208+
*,
209+
radius: float = _DEFAULT_RADIUS,
210+
min_dwell_frames: int = _DEFAULT_MIN_DWELL_FRAMES,
211+
point: str = "center",
212+
group_id: str = "group-0",
213+
) -> GroupMembership:
214+
"""Derive per-frame group membership from ``scene`` alone (deterministic).
215+
216+
The per-frame group centroid is the mean of every entity's ``point``
217+
position at that frame. An entity is a member at frame ``f`` iff it was
218+
within ``radius`` of the centroid at each of the ``min_dwell_frames``
219+
consecutive frames ending at ``f`` (a causal dwell rule). An entity
220+
missing the point or the frame counts as not-within. Re-running on the
221+
same scene always yields the same record.
222+
"""
223+
positions: dict[int, dict[str, tuple[float, float, float]]] = {}
224+
for entity in scene.entities:
225+
for entity_frame in entity.frames:
226+
xyz = entity_frame.points.get(point)
227+
if xyz is not None:
228+
positions.setdefault(entity_frame.frame, {})[entity.id] = (
229+
float(xyz[0]),
230+
float(xyz[1]),
231+
float(xyz[2]),
232+
)
233+
234+
entity_ids = sorted(entity.id for entity in scene.entities)
235+
within: dict[str, list[bool]] = {entity_id: [] for entity_id in entity_ids}
236+
frames_out: list[GroupFrameMembership] = []
237+
for frame in range(scene.num_frames):
238+
here = positions.get(frame, {})
239+
if here:
240+
cx = sum(p[0] for p in here.values()) / len(here)
241+
cy = sum(p[1] for p in here.values()) / len(here)
242+
cz = sum(p[2] for p in here.values()) / len(here)
243+
else:
244+
cx = cy = cz = 0.0
245+
for entity_id in entity_ids:
246+
pos = here.get(entity_id)
247+
is_within = pos is not None and math.dist(pos, (cx, cy, cz)) <= radius
248+
within[entity_id].append(is_within)
249+
dwell = min_dwell_frames
250+
members = [
251+
entity_id
252+
for entity_id in entity_ids
253+
if frame + 1 >= dwell and all(within[entity_id][frame + 1 - dwell : frame + 1])
254+
]
255+
frames_out.append(GroupFrameMembership(frame=frame, centroid=(cx, cy, cz), members=members))
256+
257+
member_frames = [f.frame for f in frames_out if f.members]
258+
formation_frame = member_frames[0] if member_frames else None
259+
dispersal_frame = None
260+
if formation_frame is not None:
261+
empty_after = [f.frame for f in frames_out if f.frame > formation_frame and not f.members]
262+
dispersal_frame = empty_after[0] if empty_after else None
263+
264+
return GroupMembership(
265+
group_id=group_id,
266+
radius=radius,
267+
min_dwell_frames=min_dwell_frames,
268+
point=point,
269+
formation_frame=formation_frame,
270+
dispersal_frame=dispersal_frame,
271+
frames=frames_out,
272+
)
273+
274+
275+
def write_group_json(payload: BaseModel, path: str | Path) -> dict[str, Any]:
276+
"""Write a group model (GroupMembership) to ``path`` as JSON.
277+
278+
Mirrors :func:`multicam_sim.order.write_order_json`: returns the dumped
279+
dict so a caller can assert on it without re-reading.
280+
"""
281+
data: dict[str, Any] = payload.model_dump(mode="json")
282+
Path(path).write_text(json.dumps(data, indent=2))
283+
return data

0 commit comments

Comments
 (0)