Scene framework
The YAML scene schema: seafloor, objects, motion, sonar, and outputs.
YAML-backed, declarative scene definitions that drive end-to-end
simulator + beamformer runs. The goal is reproducibility: every run
is described by a versioned SceneConfig dataclass tree that can be
saved, loaded, inherited from, and replayed bit-for-bit.
Why
Imperative scene-building scripts drift. The same scene ends up
spelled slightly differently in each runner, seeds get regenerated
rather than recorded, and comparing two runs a month apart means
diffing 200-line Python files. A SceneConfig solves this by:
- Giving every parameter a single canonical location and default.
- Making seeds explicit and stored: never derived, never auto-generated post-hoc.
- Letting scripts be 20 lines of thin wrapper over
load(name)+run(cfg). - Supporting
inherits:so scene variants share a base.
Layout
simulator/scenes/
__init__.py # public API: load, save, instantiate, run
schema.py # SceneConfig dataclasses + schema_version
io.py # YAML load/save + inheritance resolver
run.py # instantiate() and run() (sim + beamform)
library/ # curated YAML scenes (bare-name lookup)
9obj_split_zonemap.yaml
...
Top-Level SceneConfig
@dataclass
class SceneConfig:
schema_version: int = 1
name: str = ''
description: str = ''
extent: SceneExtent # x_min/x_max/y_min/y_max/cell_size
bottom: BottomConfig # zones, ripples, rocks, custom types
objects: list[dict] # raw dicts keyed by 'kind'
motion: MotionConfig # straight_line | waypoint_spline | circle | spline_track (+ perturbations)
sonar: SonarCfg # fc, fs, bandwidth, array geometry
simulation: SimulationConfig # method, density, max_range, seed
beamform: BeamformConfig # pixel_spacing, window, TVG, ...
output: OutputConfig # prefix, directory, save_port_image
schema_version=1 is enforced by from_dict: loading a YAML with a
different version raises ValueError.
motion.kind: spline_track
A centripetal Catmull-Rom curve through track.control_points, each
{x, y, alt, speed}, with attitude derived kinematically from the path
through track.dynamics. Built by simulator/track.py; edited live in
the GUI by the Trajectory panel (docs/imgui-gui.md). Example, from
simulator/scenes/library/spline_track_demo_sas.yaml:
motion:
kind: spline_track
speed: 1.583
ping_rate: 2.6383
altitude: 10.0
n_pings: 105
track:
control_points:
- {x: 0.0, y: 6.0, alt: 10.0}
- {x: 20.0, y: 10.0, alt: 10.0}
- {x: 40.0, y: 2.0, alt: 11.0}
- {x: 60.0, y: 6.0, alt: 12.0}
dynamics:
tau_yaw: 2.0
tau_pitch: 3.0
tau_roll: 1.5
max_yaw_rate: 0.35
max_pitch: 0.35
max_roll: 0.35
bank_gain: 0.2
crab: 0.0
aoa: 0.0
A control point's speed: null (the default) inherits motion.speed;
setting it overrides the speed law for that point only.
If the parent scene already has a profiles: block (profiles is the
source of truth there; the top-level motion: field is only an alias
into profiles[active_profile] and is overwritten by it on load),
override motion under profiles.<active_profile>.motion instead of a
bare top-level motion: key, or the override is silently dropped.
BottomConfig
The interesting one: wraps everything the SeafloorBuilder needs:
default_type: fallback BottomType name forScene(bottom_type=...), overridden per-cell byzone_grid.disable_auto_rocks: if True (default), zeros everyBottomType.default_rock_densitybefore building, so rocks only appear where you put them.zone_grid: 2D list of bottom-type names, row-major. Rows = along-track (X), cols = cross-track (Y).rms_heights:{type_name: rms_m}override map for FFT macro-roughness (m). Unspecified types useBottomType.rms_height_m.custom_types: list ofCustomBottomTyperegistrations applied to globalBOTTOM_TYPESbefore zone map setup. Lets a scene define, e.g.,rocky_sand= sand BSS with rock β, without editing core bottom tables.ripples: list ofZoneRipple(Tang skewed) overlays,zone=(row,col), withripple_wavelength,rms_height,direction_deg,skewness_b,seed.rocks: list ofProceduralRockGroup(zone,density,d_min,d_max,alpha,seed);zone=Nonemeans whole scene.seed: RNG seed for zone-map FFT roughness (also the texture graph's base seed fallback).texture_graph/texture_graph_path: optional Blender-style procedural node graph (inline dict, or a*.texgraph.jsonsidecar resolved relative to the YAML; the sidecar wins on load, mirroringzone_grid_path). Applied after the zone-map/ripple overlay and before legacy pockmarks/trawl/rocks; drives height, material, reflectivity gain, masks, and placements. Author it in ApertureLab's Texture Editor tab or by hand: seedocs/texture-graph.md. Demo:simulator/scenes/library/texgraph_smoke.yaml.
Objects
Stored as raw dicts keyed by kind so new primitives can be
added without schema changes. run.py::_place_object dispatches:
| kind | keys |
|---|---|
box |
x, y, x_extent, y_extent, height, ts_db, seed |
cylinder |
x, y, length, diameter, orientation, ts_db, seed |
point |
x, y, z, ts_db, n_sub?, jitter_m?, seed? |
mesh |
x, y, mesh_path, scale, rotation_z_deg, ts_db, seed |
rockan |
x, y, heading_deg, ts_db, seed |
manta |
x, y, ts_db, seed |
Angles in YAML are degrees (heading_deg, rotation_z_deg); the
dispatcher converts to radians before calling scene.add_*.
Inheritance
A YAML may start with inherits: <name-or-path>. The parent is
resolved via the same bare-name / path rules as load, deep-merged
into the child, and then parsed. The merge rules
(schema.deep_merge):
- Nested dicts recurse: child sub-keys overlay parent.
- Scalars and lists replace: no list concatenation. If you want to "add one object" to a parent's list, you must re-specify the full list in the child.
# child.yaml
inherits: 9obj_split_zonemap
simulation:
scatterer_density: 4000.0 # only this field changes
output:
prefix: 9obj_split_4000pm2
YAML 1.1 Numeric-Coercion Note
PyYAML's safe_load uses YAML 1.1 resolution, which is surprisingly
strict about scientific notation:
300000.0: parses as float OK.3.0e+5: parses as float OK (explicit sign on exponent).300.0e3: parses as a string! (No sign on exponent.)
schema._coerce_numerics rescues this by converting string values
to float/int per the target dataclass's type hints on load. Prefer
300000.0 or the explicit-sign form in YAML to avoid relying on
coercion.
Public API
from simulator.scenes import load, save, instantiate, run
cfg = load('9obj_split_zonemap') # bare name -> library/
cfg = load('/path/to/scene.yaml') # explicit path
save(cfg, '/path/to/out.yaml')
built = instantiate(cfg)
# -> {'scene': Scene, 'motion': PlatformMotion, 'sonar': SonarConfig,
# 'h5_path': str, 'cfg': SceneConfig}
result = run(cfg, force_sim=False, verbose=True)
# -> {'tdbp': SlcImage, 'h5_path': str, 'port_png': str}
Backend tag in file names. At the start of run, the output prefix is
passed through simulator.scenes.naming.method_tagged_prefix: for
simulation.method: fw any _<N>pm2 density tag is dropped (the FW
seafloor is rasterised, so density does not apply) and _fw is appended,
e.g. scene_2000pm2 -> scene_fw. PS prefixes are left as they are. A PS
run and an FW run of the same YAML therefore never overwrite each other and
the file name says which simulator produced the image.
run caches the simulated HDF5: if <output.directory>/<output.prefix>.h5
exists and force_sim=False, it skips straight to beamforming.
Iterate on beamformer params without re-paying simulation cost.
Example: Library Scene
simulator/scenes/library/9obj_split_zonemap.yaml is the canonical
9-target MUSCLE-geometry demo. Excerpt:
schema_version: 1
name: 9obj_split_zonemap
extent: {x_min: -5.0, x_max: 66.45, y_min: 0.0, y_max: 70.0, cell_size: 0.05}
bottom:
default_type: sand
disable_auto_rocks: true
seed: 42
zone_grid:
- [sand]
- [rocky_sand]
rms_heights: {sand: 0.0001, rocky_sand: 0.06}
custom_types:
- {name: rocky_sand, mean_reflectivity: 0.3, roughness_std: 0.002,
bss_db_at_20deg: -28.0, spectral_exponent: 2.0,
spectral_strength: 6.0e-4, rms_height_m: 0.06}
ripples:
- {zone: [0, 0], ripple_wavelength: 0.75, rms_height: 0.037,
direction_deg: 35.0, skewness_b: 18.0, seed: 142}
rocks:
- {zone: [0, 0], density: 1.0, d_min: 0.2, d_max: 1.0, alpha: 2.3, seed: 542}
- {zone: [1, 0], density: 2.0, d_min: 0.2, d_max: 1.0, alpha: 2.3, seed: 543}
objects:
- {kind: box, x: 20.0, y: 20.0, x_extent: 2.0, y_extent: 1.0,
height: 0.5, ts_db: -15.0, seed: 43}
- {kind: cylinder, x: 30.0, y: 20.0, length: 3.0, diameter: 0.5,
orientation: along_track, ts_db: -13.0, seed: 44}
- {kind: point, x: 40.0, y: 20.0, z: 0.0, ts_db: -25.0}
- {kind: rockan, x: 30.0, y: 32.5, heading_deg: 55.0, ts_db: -15.0, seed: 45}
- {kind: manta, x: 20.0, y: 45.0, ts_db: -15.0, seed: 46}
# ... meshes for boat, torpedo, container, boat_mine
Every ripple, rock group, and object has an explicit seed. The
scene reproduces bit-for-bit across runs and machines.
Thin Wrapper Pattern
Runner scripts should be trivial over load + run. For most scenes
you don't need a script at all: use the generic CLI:
conda run -n py312 python -m simulator.scenes.run simulator/scenes/library/9obj_split_zonemap.yaml
conda run -n py312 python -m simulator.scenes.run simulator/scenes/library/9obj_split_zonemap.yaml --force-sim # rebuild HDF5
If a scene genuinely needs a custom wrapper, keep it to this shape
(example from the retired run_scene_9obj_split.py; git history):
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from simulator.scenes import load, run
def main():
cfg = load('9obj_split_zonemap')
# CLI-style overrides via env vars (schema-driven scripts stay thin).
if 'SIM_DENSITY' in os.environ:
density = float(os.environ['SIM_DENSITY'])
cfg.simulation.scatterer_density = density
cfg.output.prefix = f'9obj_split_zonemap_{int(density)}pm2'
force_sim = bool(os.environ.get('FORCE_SIM'))
result = run(cfg, force_sim=force_sim, verbose=True)
print(f"Done. port={result.get('port_png', '<not saved>')}")
if __name__ == '__main__':
main()
When a runner grows more than ~30 lines, that's a signal to push the
complexity into a new library YAML (or a parent YAML plus inherits:
variants) rather than into Python.
Reproducibility Rule
Seeds are always explicit. Every ProceduralRockGroup,
ZoneRipple, object dict, and each of bottom.seed,
simulation.seed, simulation.fw_seed carries its own integer. A
scene file is the ground truth; never store derived random values
(e.g. a concrete rock list that was generated from a seed), and
never call time.time() or random.SystemRandom() in the runner.
Two run(cfg) invocations on the same YAML must produce the same
HDF5, the same scatterer count, and the same image.