SensorflowStudio / sensorflow /nextgen /counterfactual.py
whassan's picture
Deploy Sensorflow Studio (part 2)
6342806 verified
Raw History Blame Contribute Delete
27.9 kB
"""Counterfactual scenario engine: deterministic transformations over
bevfusion scenes.
REUSE DECISION: scenarios are NOT a new format. A counterfactual is a
transformation of a :class:`sensorflow.bevfusion.scenes.SceneSequence` — the
platform's existing scenario substrate — so everything that already consumes
those sequences (sensor simulators, both perception engines, the evaluate
module) runs on counterfactuals unchanged. We reconstruct world-frame actor
kinematics from the source frames, transform them, and re-render frames with
the same conventions (ego +x at 10 m/s, culling bounds, occlusion flags) as
scenes.generate_sequences.
Transformation catalogue
------------------------
environment.* clear->fog / clear->rain / day->night / glare / wet_road
actors.* sudden_brake, swerve, stationary_to_crossing,
occluded_emergence, teleport (stress transform: physically
implausible BY DESIGN, used to prove the validity gate rejects)
scene.* add_construction_zone, pedestrian_density, add_occlusion,
distance_shift, lane_shift
Every generated scenario carries provenance (source scene id, recipe, seed,
generator + version) and the data label COUNTERFACTUAL. Provenance is
persisted with the scenario and carried into every downstream report.
Extended environment conditions (fog / glare / wet_road) are not modeled by
the bevfusion sensor simulators (which know day/night, clear/rain). They are
recorded as environment tags here and applied as documented, parameterized
detection-degradation factors by the closed-loop perception model
(closedloop.PERCEPTION_CONDITION_EFFECTS) and by the validity gate's sensor
consistency expectations.
"""
from __future__ import annotations
import math
from typing import Callable, Dict, List, Optional
from uuid import uuid4
import numpy as np
from sensorflow.bevfusion.scenes import (
CLASS_DIMS, DT, EGO_SPEED, GTBox, SceneFrame, SceneSequence,
generate_sequences,
)
from sensorflow.nextgen import store
from sensorflow.nextgen.models import (
CounterfactualScenario, DataLabel, Provenance, TransformationStep,
)
from sensorflow.nextgen.worldmodel import (
ActorTrack, SceneTransformer, TransformedScene,
)
# Culling bounds identical to scenes.generate_sequences.
_X_MIN, _X_MAX, _Y_MAX = 3.0, 78.0, 22.0
# ------------------------------------------------------- reconstruction
def reconstruct_actors(seq: SceneSequence) -> List[ActorTrack]:
"""Recover world-frame constant-velocity kinematics from a sequence.
Source scenes are constant-velocity by construction (scenes.py), so a
least-squares linear fit over the observed frames recovers the exact
generating kinematics; frames where the actor was culled are filled by
extrapolation.
"""
n_frames = len(seq.frames)
obs: Dict[str, Dict] = {}
for frame in seq.frames:
ego_x = EGO_SPEED * DT * frame.index
for gt in frame.gt:
rec = obs.setdefault(gt.instance_id, {
"cls": gt.class_name, "dims": gt.bbox_3d[3:6],
"yaw": gt.bbox_3d[6], "t": [], "x": [], "y": [],
"occ": {},
})
rec["t"].append(frame.index * DT)
rec["x"].append(gt.bbox_3d[0] + ego_x) # world x
rec["y"].append(gt.bbox_3d[1]) # world y (ego drives +x)
rec["occ"][frame.index] = gt.occluded
actors: List[ActorTrack] = []
for iid, rec in obs.items():
t = np.asarray(rec["t"])
if t.size >= 2:
vx, x0 = np.polyfit(t, np.asarray(rec["x"]), 1)
vy, y0 = np.polyfit(t, np.asarray(rec["y"]), 1)
else:
x0, y0, vx, vy = rec["x"][0], rec["y"][0], 0.0, 0.0
states = []
for fi in range(n_frames):
ti = fi * DT
states.append({
"x": float(x0 + vx * ti), "y": float(y0 + vy * ti),
"vx": float(vx), "vy": float(vy), "yaw": float(rec["yaw"]),
"occluded": bool(rec["occ"].get(fi, False)),
})
actors.append(ActorTrack(instance_id=iid, class_name=rec["cls"],
dims=[float(d) for d in rec["dims"]],
states=states))
return actors
def render_sequence(actors: List[ActorTrack], environment: Dict[str, str],
n_frames: int, sequence_id: str) -> SceneSequence:
"""Re-render frames from world tracks with the scenes.py conventions."""
seq = SceneSequence(
sequence_id=sequence_id,
time_of_day=environment.get("time_of_day", "day"),
weather=environment.get("weather", "clear"))
for fi in range(n_frames):
ego_x = EGO_SPEED * DT * fi
frame = SceneFrame(frame_id=f"{sequence_id}-f{fi:04d}", index=fi)
for actor in actors:
st = actor.states[fi]
if st.get("absent"):
continue
rel_x, rel_y = st["x"] - ego_x, st["y"]
if rel_x < _X_MIN or rel_x > _X_MAX or abs(rel_y) > _Y_MAX:
continue
l, w, h = actor.dims
frame.gt.append(GTBox(
instance_id=actor.instance_id, class_name=actor.class_name,
bbox_3d=[round(rel_x, 3), round(rel_y, 3), round(h / 2, 3),
l, w, h, round(st["yaw"], 4)],
occluded=bool(st["occluded"]),
distance=round(math.hypot(rel_x, rel_y), 3)))
seq.frames.append(frame)
return seq
# ------------------------------------------------------- transform helpers
def _brake_from(states: List[Dict], k: int, decel: float) -> None:
"""Re-integrate a track from frame k braking at `decel` along its fixed
heading; the speed profile is a clamped scalar (stops, never reverses)."""
st = states[k]
speed0 = math.hypot(st["vx"], st["vy"])
if speed0 < 1e-6:
return
ux, uy = st["vx"] / speed0, st["vy"] / speed0
for fi in range(k + 1, len(states)):
prev = states[fi - 1]
speed = max(0.0, math.hypot(prev["vx"], prev["vy"]) - decel * DT)
vx, vy = ux * speed, uy * speed
states[fi].update({
"x": prev["x"] + vx * DT, "y": prev["y"] + vy * DT,
"vx": vx, "vy": vy, "yaw": st["yaw"],
})
def _ramped_lateral(y0: float, vy_target: float, t_start: float, ti: float,
ramp_a: float = 6.0) -> tuple:
"""Lateral position/velocity with a bounded-acceleration ramp to
vy_target starting at t_start (keeps transforms physically plausible:
ramp_a stays below the validity gate's pedestrian limit)."""
if ti <= t_start:
return y0, 0.0
t = ti - t_start
t_ramp = abs(vy_target) / ramp_a
if t <= t_ramp:
vy = math.copysign(ramp_a * t, vy_target)
y = y0 + math.copysign(0.5 * ramp_a * t * t, vy_target)
else:
vy = vy_target
y = y0 + math.copysign(0.5 * ramp_a * t_ramp * t_ramp, vy_target) \
+ vy_target * (t - t_ramp)
return y, vy
def _clear_x(actors: List[ActorTrack], n_frames: int, y: float,
rng: np.random.Generator, x_lo: float = 18.0, x_hi: float = 60.0,
min_dist: float = 4.0) -> float:
"""Longitudinal spot whose distance to every existing trajectory at the
given y stays >= min_dist (best-of-K deterministic search)."""
best_x, best_d = (x_lo + x_hi) / 2, -1.0
for _ in range(24):
x = float(rng.uniform(x_lo, x_hi))
d = min((math.hypot(st["x"] - x, st["y"] - y)
for a in actors for st in a.states[::4]), default=1e9)
if d > best_d:
best_x, best_d = x, d
if d >= min_dist:
return x
return best_x
def _lead_actor(actors: List[ActorTrack]) -> Optional[ActorTrack]:
"""Nearest vehicle-like actor ahead of ego near the ego lane at t=0;
falls back to the fastest lane-following motorized actor (braking a
slow crossing pedestrian is not a meaningful sudden-brake scenario)."""
best, best_x = None, float("inf")
for a in actors:
st = a.states[0]
if a.class_name in ("vehicle", "truck") and st["x"] > 5.0 and abs(st["y"]) < 4.0:
if st["x"] < best_x:
best, best_x = a, st["x"]
if best is not None:
return best
motorized = [a for a in actors
if a.class_name in ("vehicle", "truck", "motorcycle")
and abs(a.states[0]["vy"]) < 0.5]
if motorized:
return max(motorized, key=lambda a: math.hypot(a.states[0]["vx"],
a.states[0]["vy"]))
return actors[0] if actors else None
def _pick(actors: List[ActorTrack], params: Dict, rng: np.random.Generator,
prefer_class: Optional[str] = None) -> Optional[ActorTrack]:
iid = params.get("instance_id")
if iid:
for a in actors:
if a.instance_id == iid:
return a
if prefer_class:
cands = [a for a in actors if a.class_name == prefer_class]
if cands:
return cands[int(rng.integers(0, len(cands)))]
return actors[int(rng.integers(0, len(actors)))] if actors else None
# ------------------------------------------------------- transformations
# Each op: (actors, env, rng, params, n_frames) -> note string.
def _op_env(tag: str, value: str) -> Callable:
def op(actors, env, rng, params, n_frames):
env[tag] = value
return f"environment: {tag} -> {value}"
return op
def _op_sudden_brake(actors, env, rng, params, n_frames):
target = _pick(actors, params, rng, prefer_class="vehicle") if params.get("instance_id") \
else _lead_actor(actors)
if target is None:
return "sudden_brake: no actor available"
t_start = float(params.get("t_start_s", 1.0))
decel = float(params.get("decel_mps2", 7.5))
k = max(0, min(n_frames - 1, int(round(t_start / DT))))
st = target.states[k]
if math.hypot(st["vx"], st["vy"]) < 1e-6:
return f"sudden_brake: target {target.instance_id} already stationary"
_brake_from(target.states, k, decel)
# Chain reaction: same-lane followers brake 0.8 s later (traffic is not
# frozen — otherwise the counterfactual plants unphysical rear-endings
# that the validity gate would rightly reject).
k2 = min(n_frames - 1, k + int(round(0.8 / DT)))
speed_t = math.hypot(st["vx"], st["vy"])
ux, uy = st["vx"] / speed_t, st["vy"] / speed_t
n_followers = 0
for a in actors:
if a is target:
continue
s0 = a.states[k]
along = (st["x"] - s0["x"]) * ux + (st["y"] - s0["y"]) * uy
lateral = abs(-(st["x"] - s0["x"]) * uy + (st["y"] - s0["y"]) * ux)
same_dir = (s0["vx"] * ux + s0["vy"] * uy) > 0.5
if along > 0 and lateral < 2.5 and same_dir:
_brake_from(a.states, k2, min(decel, 8.0))
n_followers += 1
return (f"sudden_brake: {target.instance_id} decelerates at "
f"{decel} m/s^2 from t={t_start}s; {n_followers} follower(s) "
f"chain-brake at t={t_start + 0.8}s")
def _op_swerve(actors, env, rng, params, n_frames):
target = _pick(actors, params, rng, prefer_class="vehicle")
if target is None:
return "swerve: no actor available"
t_start = float(params.get("t_start_s", 1.0))
lat_acc = float(params.get("lateral_accel_mps2", 2.5))
duration = float(params.get("duration_s", 1.2))
k = max(0, min(n_frames - 1, int(round(t_start / DT))))
k_end = min(n_frames - 1, int(round((t_start + duration) / DT)))
sign = -1.0 if target.states[k]["y"] > 0 else 1.0 # swerve toward ego lane
for fi in range(k + 1, n_frames):
prev = target.states[fi - 1]
ay = sign * lat_acc if fi <= k_end else 0.0
vy = prev["vy"] + ay * DT
target.states[fi].update({
"x": prev["x"] + prev["vx"] * DT, "y": prev["y"] + vy * DT,
"vx": prev["vx"], "vy": vy,
"yaw": math.atan2(vy, prev["vx"]) if prev["vx"] or vy else prev["yaw"],
})
return (f"swerve: {target.instance_id} lateral accel {sign * lat_acc:+.1f} "
f"m/s^2 for {duration}s from t={t_start}s")
def _op_stationary_to_crossing(actors, env, rng, params, n_frames):
target = _pick(actors, params, rng, prefer_class="pedestrian")
if target is None:
return "stationary_to_crossing: no actor available"
t_start = float(params.get("t_start_s", 0.8))
st0 = target.states[0]
# Time the crossing so the actor reaches the ego lane center (y=0) when
# a nominal-speed ego reaches its x -> a genuine conflict geometry.
t_conflict = max(t_start + 0.8, st0["x"] / EGO_SPEED)
vy = -st0["y"] / max(t_conflict - t_start, 0.5)
vy = float(np.clip(vy, -2.5, 2.5)) # pedestrian-plausible speed
for fi in range(n_frames):
ti = fi * DT
s = target.states[fi]
y, vy_i = _ramped_lateral(st0["y"], vy, t_start, ti)
s.update({"x": st0["x"], "y": y, "vx": 0.0, "vy": vy_i,
"yaw": (math.pi / 2 if vy > 0 else -math.pi / 2)
if vy_i else s["yaw"]})
return (f"stationary_to_crossing: {target.instance_id} crosses at "
f"{vy:+.2f} m/s from t={t_start}s (conflict ~t={t_conflict:.1f}s)")
def _op_occluded_emergence(actors, env, rng, params, n_frames):
"""Plant an occluder truck and a pedestrian that emerges from behind it."""
t_emerge = float(params.get("t_emerge_s", 1.6))
y_side = float(params.get("y_m", 4.0))
tl, tw, th = CLASS_DIMS["truck"]
x_emerge = float(params.get("x_m", _clear_x(actors, n_frames, y_side, rng,
x_lo=EGO_SPEED * t_emerge + 10.0,
x_hi=EGO_SPEED * t_emerge + 34.0)))
truck = ActorTrack(
instance_id=f"cf-occluder-{len(actors)}", class_name="truck",
dims=[tl, tw, th],
states=[{"x": x_emerge, "y": y_side + 1.8, "vx": 0.0, "vy": 0.0,
"yaw": 0.0, "occluded": False} for _ in range(n_frames)])
pl, pw, ph = CLASS_DIMS["pedestrian"]
vy = -1.9
states = []
for fi in range(n_frames):
ti = fi * DT
y, vy_i = _ramped_lateral(y_side, vy, t_emerge, ti)
states.append({"x": x_emerge + tl / 2 + 0.8, "y": y,
"vx": 0.0, "vy": vy_i, "yaw": -math.pi / 2,
"occluded": ti <= t_emerge})
ped = ActorTrack(instance_id=f"cf-emergent-ped-{len(actors)}",
class_name="pedestrian", dims=[pl, pw, ph], states=states)
actors.extend([truck, ped])
return (f"occluded_emergence: pedestrian emerges from behind truck at "
f"x={x_emerge:.1f} m, t={t_emerge}s")
def _op_teleport(actors, env, rng, params, n_frames):
"""STRESS TRANSFORM: instantaneous position jump — physically implausible
by construction. Exists so tests/demos can prove the validity gate
rejects implausible scenarios; never part of production recipes."""
target = _pick(actors, params, rng)
if target is None:
return "teleport: no actor available"
k = max(1, min(n_frames - 1, int(round(float(params.get("t_s", 1.0)) / DT))))
jump = float(params.get("jump_m", 25.0))
for fi in range(k, n_frames):
target.states[fi]["x"] += jump
return f"teleport: {target.instance_id} jumps {jump} m at frame {k} (IMPLAUSIBLE)"
def _op_construction_zone(actors, env, rng, params, n_frames):
length = float(params.get("length_m", 20.0))
# Deterministic search for a barrier line clear of existing trajectories.
y_line = params.get("y_m")
x0 = params.get("x_start_m")
if x0 is None or y_line is None:
best, best_d = (30.0, 3.4), -1.0
for _ in range(36):
cx = float(rng.uniform(20.0, 55.0 - length / 2))
cy = float(rng.choice([3.4, 4.8, 6.2, -3.4, -4.8]))
d = min((math.hypot(min(max(st["x"], cx), cx + length) - st["x"],
cy - st["y"])
for a in actors for st in a.states[::2]), default=1e9)
if d > best_d:
best, best_d = (cx, cy), d
if d >= 3.2:
break
x0 = x0 if x0 is not None else best[0]
y_line = y_line if y_line is not None else best[1]
x0, y_line = float(x0), float(y_line)
n_barriers = max(2, int(length / 8))
for bi in range(n_barriers):
actors.append(ActorTrack(
instance_id=f"cf-construction-{bi}", class_name="truck",
dims=[6.0, 2.2, 2.4],
states=[{"x": x0 + bi * (length / max(n_barriers - 1, 1)),
"y": y_line, "vx": 0.0, "vy": 0.0, "yaw": 0.0,
"occluded": False} for _ in range(n_frames)]))
# Lane-following traffic near the closed shoulder shifts away, with a
# smooth 10 m taper. Membership is decided ONCE per actor (initial lane +
# longitudinal travel), so no temporal discontinuity is introduced.
def taper(x: float) -> float:
ramp = 10.0
if x < x0 - ramp or x > x0 + length + ramp:
return 0.0
if x < x0:
u = (x - (x0 - ramp)) / ramp
elif x > x0 + length:
u = ((x0 + length + ramp) - x) / ramp
else:
u = 1.0
return u * u * (3 - 2 * u) # smoothstep
away = -1.0 if y_line > 0 else 1.0 # shift direction: away from barriers
for a in actors:
if a.instance_id.startswith("cf-construction"):
continue
st0 = a.states[0]
lane_following = abs(st0["vy"]) < 0.5
in_band = abs(st0["y"] - y_line) <= 4.0
if lane_following and in_band:
for st in a.states:
st["y"] += away * 1.6 * taper(st["x"])
env["construction_zone"] = f"x={x0:.0f}..{x0 + length:.0f}m"
return f"add_construction_zone: {n_barriers} barriers at x={x0:.0f}..{x0+length:.0f} m"
def _op_pedestrian_density(actors, env, rng, params, n_frames):
n = int(params.get("n", 4))
pl, pw, ph = CLASS_DIMS["pedestrian"]
for pi in range(n):
y = float(rng.uniform(4.0, 10.0)) * (1 if pi % 2 == 0 else -1)
x = _clear_x(actors, n_frames, y, rng, x_lo=15.0, x_hi=65.0)
vy = float(rng.uniform(0.8, 1.6)) * (-1 if y > 0 else 1)
states = []
for fi in range(n_frames):
yy, vy_i = _ramped_lateral(y, vy, 0.2, fi * DT)
states.append({"x": x, "y": yy, "vx": 0.0, "vy": vy_i,
"yaw": math.copysign(math.pi / 2, vy),
"occluded": False})
actors.append(ActorTrack(
instance_id=f"cf-ped-{pi}", class_name="pedestrian",
dims=[pl, pw, ph], states=states))
return f"pedestrian_density: +{n} crossing pedestrians"
def _op_add_occlusion(actors, env, rng, params, n_frames):
target = _pick(actors, params, rng)
if target is None:
return "add_occlusion: no actor available"
start = int(params.get("start_frame", max(2, n_frames // 3)))
length = int(params.get("length_frames", 4))
for fi in range(start, min(start + length, n_frames)):
target.states[fi]["occluded"] = True
return f"add_occlusion: {target.instance_id} occluded frames {start}..{start+length-1}"
def _op_distance_shift(actors, env, rng, params, n_frames):
dx = float(params.get("delta_m", 10.0))
for a in actors:
for st in a.states:
st["x"] += dx
return f"distance_shift: all actors shifted {dx:+.1f} m longitudinally"
def _op_lane_shift(actors, env, rng, params, n_frames):
dy = float(params.get("delta_m", 1.5))
for a in actors:
for st in a.states:
st["y"] += dy
return f"lane_shift: road geometry shifted {dy:+.1f} m laterally"
TRANSFORMATIONS: Dict[str, Callable] = {
"environment.clear_to_fog": _op_env("weather_extended", "fog"),
"environment.clear_to_rain": _op_env("weather", "rain"),
"environment.day_to_night": _op_env("time_of_day", "night"),
"environment.add_glare": _op_env("glare", "sunset_glare"),
"environment.wet_road": _op_env("road_surface", "wet"),
"actors.sudden_brake": _op_sudden_brake,
"actors.swerve": _op_swerve,
"actors.stationary_to_crossing": _op_stationary_to_crossing,
"actors.occluded_emergence": _op_occluded_emergence,
"actors.teleport": _op_teleport,
"scene.add_construction_zone": _op_construction_zone,
"scene.pedestrian_density": _op_pedestrian_density,
"scene.add_occlusion": _op_add_occlusion,
"scene.distance_shift": _op_distance_shift,
"scene.lane_shift": _op_lane_shift,
}
def transformation_catalogue() -> List[Dict]:
"""Recipe-builder metadata for the UI."""
docs = {
"environment.clear_to_fog": {"family": "environment", "params": {}},
"environment.clear_to_rain": {"family": "environment", "params": {}},
"environment.day_to_night": {"family": "environment", "params": {}},
"environment.add_glare": {"family": "environment", "params": {}},
"environment.wet_road": {"family": "environment", "params": {}},
"actors.sudden_brake": {"family": "actors",
"params": {"t_start_s": 1.0, "decel_mps2": 7.5}},
"actors.swerve": {"family": "actors",
"params": {"t_start_s": 1.0, "lateral_accel_mps2": 2.5,
"duration_s": 1.2}},
"actors.stationary_to_crossing": {"family": "actors",
"params": {"t_start_s": 0.8}},
"actors.occluded_emergence": {"family": "actors",
"params": {"t_emerge_s": 1.6}},
"actors.teleport": {"family": "actors (stress/test only)",
"params": {"t_s": 1.0, "jump_m": 25.0}},
"scene.add_construction_zone": {"family": "scene",
"params": {"x_start_m": 30.0, "length_m": 20.0}},
"scene.pedestrian_density": {"family": "scene", "params": {"n": 4}},
"scene.add_occlusion": {"family": "scene",
"params": {"start_frame": 8, "length_frames": 4}},
"scene.distance_shift": {"family": "scene", "params": {"delta_m": 10.0}},
"scene.lane_shift": {"family": "scene", "params": {"delta_m": 1.5}},
}
return [{"kind": k, **v} for k, v in docs.items()]
# ------------------------------------------------------- the live transformer
class DeterministicSceneTransformer(SceneTransformer):
"""The internal world model: deterministic rule-based scene transformer.
Pure function of (source, recipe, seed); all randomness (actor selection,
added-actor placement) is drawn from a generator seeded with the given
seed, so a scenario's provenance record fully reproduces it.
"""
name = "nextgen.worldmodel.DeterministicSceneTransformer"
version = "1.0"
def transform(self, source: SceneSequence, recipe: List[TransformationStep],
seed: int) -> TransformedScene:
rng = np.random.default_rng([seed, 0xC0FFEE])
n_frames = len(source.frames)
actors = reconstruct_actors(source)
env: Dict[str, str] = {"time_of_day": source.time_of_day,
"weather": source.weather}
notes: List[str] = []
for step in recipe:
op = TRANSFORMATIONS.get(step.kind)
if op is None:
raise ValueError(f"unknown transformation {step.kind!r}; "
f"known: {sorted(TRANSFORMATIONS)}")
notes.append(op(actors, env, rng, step.params, n_frames))
seq = render_sequence(actors, env, n_frames,
sequence_id=f"cf-{source.sequence_id}-{seed}")
return TransformedScene(sequence=seq, actors=actors, environment=env,
applied=list(recipe), notes=notes)
# ------------------------------------------------------- generation + storage
def _actor_dump(a: ActorTrack) -> Dict:
return {"instance_id": a.instance_id, "class_name": a.class_name,
"dims": a.dims, "states": a.states}
def _actor_load(d: Dict) -> ActorTrack:
return ActorTrack(instance_id=d["instance_id"], class_name=d["class_name"],
dims=d["dims"], states=d["states"])
def generate_counterfactuals(recipe: List[TransformationStep], seed: int = 7,
n_scenarios: int = 3,
frames_per_sequence: int = 40) -> List[CounterfactualScenario]:
"""Generate counterfactual scenarios from bevfusion source scenes and
persist them (with full provenance) under runs/nextgen/counterfactuals/."""
sources = generate_sequences(n_sequences=n_scenarios,
frames_per_sequence=frames_per_sequence,
seed=seed)
from sensorflow.nextgen.validity import overlap_pairs, scene_features
transformer = DeterministicSceneTransformer()
out: List[CounterfactualScenario] = []
for si, source in enumerate(sources):
scenario_seed = seed * 1009 + si
source_overlaps = overlap_pairs(reconstruct_actors(source))
source_feats = {k: [round(float(x), 3) for x in v]
for k, v in scene_features(source).items()}
result = transformer.transform(source, recipe, scenario_seed)
scenario_id = f"cfs-{uuid4().hex[:10]}"
scenario = CounterfactualScenario(
scenario_id=scenario_id,
provenance=Provenance(
source_scene_id=source.sequence_id,
recipe=list(recipe), seed=scenario_seed,
generator=transformer.name,
generator_version=transformer.version,
data_label=DataLabel.COUNTERFACTUAL),
n_frames=len(result.sequence.frames),
n_actors=len(result.actors),
environment=result.environment)
store.write_json({
"scenario": scenario.model_dump(mode="json"),
"sequence": result.sequence.model_dump(mode="json"),
"actors": [_actor_dump(a) for a in result.actors],
"notes": result.notes,
"source_overlap_pairs": [sorted(p) for p in source_overlaps],
"source_features": source_feats,
}, "counterfactuals", f"{scenario_id}.json")
out.append(scenario)
return out
def load_bundle(scenario_id: str) -> Optional[Dict]:
"""Load a persisted scenario bundle: scenario + sequence + world tracks."""
raw = store.read_json("counterfactuals", f"{scenario_id}.json")
if raw is None:
return None
return {
"scenario": CounterfactualScenario(**raw["scenario"]),
"sequence": SceneSequence(**raw["sequence"]),
"actors": [_actor_load(d) for d in raw["actors"]],
"notes": raw.get("notes", []),
"source_overlap_pairs": {frozenset(p) for p in
raw.get("source_overlap_pairs", [])},
"source_features": raw.get("source_features"),
}
def save_scenario(scenario: CounterfactualScenario) -> None:
"""Update the persisted scenario record (e.g. after validity gating)."""
raw = store.read_json("counterfactuals", f"{scenario.scenario_id}.json")
if raw is None:
raise KeyError(f"unknown scenario {scenario.scenario_id}")
raw["scenario"] = scenario.model_dump(mode="json")
store.write_json(raw, "counterfactuals", f"{scenario.scenario_id}.json")
def list_scenarios() -> List[CounterfactualScenario]:
out = []
for sid in store.list_json("counterfactuals"):
raw = store.read_json("counterfactuals", f"{sid}.json")
if raw:
out.append(CounterfactualScenario(**raw["scenario"]))
return out