From 0ced262a37826409b04516a4db5b31c09303c90c Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 10:25:38 +0200 Subject: [PATCH 1/8] Add seeded RandomizationSpec for domain randomization (#41) Typed pydantic spec module: background, light, and distractor sub-specs, each knob a closed (min, max) interval validated against inversion. Sampling is pure and seeded via numpy.random.default_rng with a fixed draw order, so a spec + seed deterministically yields a concrete RandomizationSample. Co-Authored-By: Kimi K3 --- src/multicam_sim/randomization.py | 267 ++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 src/multicam_sim/randomization.py diff --git a/src/multicam_sim/randomization.py b/src/multicam_sim/randomization.py new file mode 100644 index 0000000..e165713 --- /dev/null +++ b/src/multicam_sim/randomization.py @@ -0,0 +1,267 @@ +"""Seeded domain randomization for the Scene builder (#41). + +Synthetic-to-real robustness needs varied scenes. This module adds typed, +seeded randomization knobs — background colour, key-light direction/intensity, +and a count of distractor objects — so one scenario can be sampled +deterministically N ways. + +The shape mirrors :mod:`multicam_sim.noise`: a frozen pydantic *spec* carries +the knob ranges, sampling is explicit and pure +(``numpy.random.default_rng(seed)``, never the global RNG), and everything is +additive and off by default so an un-randomized scene is byte-identical to +today's output. + +* **Specs** (:class:`BackgroundSpec`, :class:`LightSpec`, + :class:`DistractorSpec`) express every knob as a closed ``(min, max)`` + interval and reject an inverted interval (``min > max``) at validation time. +* :meth:`RandomizationSpec.sample` takes a seed and returns a concrete, typed, + fully-determined :class:`RandomizationSample` — not a mutated scene. Draws + are taken in a FIXED order (background channels, light intensity/azimuth/ + elevation, distractor count, then three coordinates per distractor), so the + same spec and the same seed always produce a byte-identical sample. +* The concrete results (:class:`Background`, :class:`Light`) are ordinary + scene-level fields; the builder applies a sample and records the + :class:`RandomizationRecord` (spec + seed) on the scene as an optional + sidecar, so a randomized run is reproducible from its own output. +""" + +from __future__ import annotations + +import math + +import numpy as np +from pydantic import BaseModel, ConfigDict, field_validator, model_validator + +Vec3 = tuple[float, float, float] + + +def _check_interval(name: str, lo: float, hi: float) -> None: + """Reject an inverted closed interval (the shared ``min > max`` check).""" + if lo > hi: + raise ValueError(f"{name}: min ({lo}) must be <= max ({hi})") + + +class BackgroundSpec(BaseModel): + """Uniform RGB background colour range; channels sampled independently. + + Channels are in ``[0, 1]`` (the renderer's colour convention, e.g. + ``pyrender.Scene(bg_color=...)``). The default interval stays dark, around + today's fixed black background. + """ + + model_config = ConfigDict(frozen=True) + + rgb_min: Vec3 = (0.0, 0.0, 0.0) + rgb_max: Vec3 = (0.2, 0.2, 0.2) + + @field_validator("rgb_min", "rgb_max") + @classmethod + def _unit_channels(cls, value: Vec3) -> Vec3: + for channel in value: + if not 0.0 <= channel <= 1.0: + raise ValueError("background channels must lie in [0, 1]") + return value + + @model_validator(mode="after") + def _min_le_max(self) -> BackgroundSpec: + for lo, hi in zip(self.rgb_min, self.rgb_max, strict=True): + _check_interval("background rgb", lo, hi) + return self + + +class LightSpec(BaseModel): + """Key-light intensity and direction ranges. + + * ``intensity`` — renderer light intensity units; today's fixed key light + is ``3.0``, so the default interval ``(2.0, 4.0)`` varies around it. + * ``azimuth_deg`` / ``elevation_deg`` — the direction FROM the scene TO the + light, in **degrees** (the codebase's angle convention): azimuth measured + in the world XY plane from +X toward +Y, elevation above the XY plane. + The light shines along the negative of that vector (see + :meth:`Light.direction`). + """ + + model_config = ConfigDict(frozen=True) + + intensity: tuple[float, float] = (2.0, 4.0) + azimuth_deg: tuple[float, float] = (0.0, 360.0) + elevation_deg: tuple[float, float] = (30.0, 90.0) + + @field_validator("intensity") + @classmethod + def _non_negative_intensity(cls, value: tuple[float, float]) -> tuple[float, float]: + if value[0] < 0.0: + raise ValueError("light intensity must be >= 0") + return value + + @field_validator("elevation_deg") + @classmethod + def _elevation_in_range(cls, value: tuple[float, float]) -> tuple[float, float]: + for angle in value: + if not -90.0 <= angle <= 90.0: + raise ValueError("light elevation must lie in [-90, 90] degrees") + return value + + @model_validator(mode="after") + def _min_le_max(self) -> LightSpec: + _check_interval("light intensity", *self.intensity) + _check_interval("light azimuth_deg", *self.azimuth_deg) + _check_interval("light elevation_deg", *self.elevation_deg) + return self + + +class DistractorSpec(BaseModel): + """A count of static distractor objects placed uniformly in a world box. + + ``count`` is an inclusive integer interval; each sampled distractor is a + non-target entity at a fixed position whose ``x``/``y``/``z`` coordinates + (scene units) are drawn independently from the given intervals. The default + box is a 4x4x1 metre region at floor level around the origin. + """ + + model_config = ConfigDict(frozen=True) + + count: tuple[int, int] = (1, 3) + x: tuple[float, float] = (-2.0, 2.0) + y: tuple[float, float] = (-2.0, 2.0) + z: tuple[float, float] = (0.0, 1.0) + + @field_validator("count") + @classmethod + def _non_negative_count(cls, value: tuple[int, int]) -> tuple[int, int]: + if value[0] < 0: + raise ValueError("distractor count must be >= 0") + return value + + @model_validator(mode="after") + def _min_le_max(self) -> DistractorSpec: + if self.count[0] > self.count[1]: + raise ValueError( + f"distractor count: min ({self.count[0]}) must be <= max ({self.count[1]})" + ) + _check_interval("distractor x", *self.x) + _check_interval("distractor y", *self.y) + _check_interval("distractor z", *self.z) + return self + + +class Background(BaseModel): + """A concrete, sampled background: one RGB colour, channels in ``[0, 1]``.""" + + model_config = ConfigDict(frozen=True) + + rgb: Vec3 + + @field_validator("rgb") + @classmethod + def _unit_channels(cls, value: Vec3) -> Vec3: + for channel in value: + if not 0.0 <= channel <= 1.0: + raise ValueError("background channels must lie in [0, 1]") + return value + + +class Light(BaseModel): + """A concrete, sampled key light: intensity plus direction. + + ``azimuth_deg`` / ``elevation_deg`` give the direction FROM the scene TO + the light (azimuth in the world XY plane from +X toward +Y, elevation above + the XY plane); :meth:`direction` is the unit vector the light shines along. + """ + + model_config = ConfigDict(frozen=True) + + intensity: float + azimuth_deg: float + elevation_deg: float + + def direction(self) -> Vec3: + """Unit vector the light travels along (the negated scene->light vector).""" + az = math.radians(self.azimuth_deg) + el = math.radians(self.elevation_deg) + return ( + -math.cos(el) * math.cos(az), + -math.cos(el) * math.sin(az), + -math.sin(el), + ) + + +class RandomizationSample(BaseModel): + """The concrete, fully-determined result of sampling a spec once. + + Pure data: applying it to a scene is the builder's job. ``None``/empty + members correspond to knobs that were absent from the spec. + """ + + model_config = ConfigDict(frozen=True) + + background: Background | None = None + light: Light | None = None + distractor_positions: list[Vec3] = [] + + +class RandomizationSpec(BaseModel): + """The seeded randomization knobs for one scenario. + + Every knob is optional and off by default: an all-``None`` spec samples to + an empty :class:`RandomizationSample`. ``seed`` is NOT stored here — it is + passed to :meth:`sample` and recorded alongside the spec in the scene's + :class:`RandomizationRecord` sidecar. + """ + + model_config = ConfigDict(frozen=True) + + background: BackgroundSpec | None = None + light: LightSpec | None = None + distractors: DistractorSpec | None = None + + def sample(self, seed: int) -> RandomizationSample: + """Draw one concrete sample under ``seed`` (pure and deterministic). + + Uses ``numpy.random.default_rng(seed)`` with a fixed draw order, so the + same spec and seed produce a byte-identical sample and different seeds + produce different ones. + """ + rng = np.random.default_rng(seed) + + background = None + if self.background is not None: + rgb = rng.uniform(self.background.rgb_min, self.background.rgb_max) + background = Background(rgb=(float(rgb[0]), float(rgb[1]), float(rgb[2]))) + + light = None + if self.light is not None: + light = Light( + intensity=float(rng.uniform(*self.light.intensity)), + azimuth_deg=float(rng.uniform(*self.light.azimuth_deg)), + elevation_deg=float(rng.uniform(*self.light.elevation_deg)), + ) + + positions: list[Vec3] = [] + if self.distractors is not None: + spec = self.distractors + n = int(rng.integers(spec.count[0], spec.count[1] + 1)) + for _ in range(n): + lo = [spec.x[0], spec.y[0], spec.z[0]] + hi = [spec.x[1], spec.y[1], spec.z[1]] + xyz = rng.uniform(lo, hi) + positions.append((float(xyz[0]), float(xyz[1]), float(xyz[2]))) + + return RandomizationSample( + background=background, light=light, distractor_positions=positions + ) + + +class RandomizationRecord(BaseModel): + """Provenance sidecar: the spec and seed that produced a scene. + + Stored on :class:`~multicam_sim.scene.Scene` as an optional field — absent + by default, present and round-tripping when randomization was used — so a + randomized run is reproducible from its own output: + ``record.spec.sample(record.seed)`` regenerates the exact applied sample. + """ + + model_config = ConfigDict(frozen=True) + + spec: RandomizationSpec + seed: int From f42cd216f7aa9849d4ed57be0d349ffd8c5e3d42 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 10:26:00 +0200 Subject: [PATCH 2/8] Wire randomization into SceneBuilder with additive Scene fields (#41) SceneBuilder.randomize(spec, seed=...) samples the spec and applies it: sampled background/light land on new optional Scene fields, distractors go through the existing .distractor entry point, and a RandomizationRecord sidecar (spec + seed) rides on the scene. All fields default to None, so an un-randomized scene is byte-identical; the pyrender backend consumes the sampled background/light when present. Co-Authored-By: Kimi K3 --- src/multicam_sim/__init__.py | 18 ++++++++++++++++++ src/multicam_sim/dsl/builder.py | 30 +++++++++++++++++++++++++++++- src/multicam_sim/dsl/render.py | 29 +++++++++++++++++++++++++++-- src/multicam_sim/scene.py | 10 ++++++++++ 4 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/multicam_sim/__init__.py b/src/multicam_sim/__init__.py index 13e68ad..6a8c9db 100644 --- a/src/multicam_sim/__init__.py +++ b/src/multicam_sim/__init__.py @@ -58,6 +58,16 @@ PossessionTimeline, write_possession_json, ) +from .randomization import ( + Background, + BackgroundSpec, + DistractorSpec, + Light, + LightSpec, + RandomizationRecord, + RandomizationSample, + RandomizationSpec, +) from .scene import Scene from .smoke import build_multi_entity_scene, build_pose_smoke_scene, build_smoke_scene from .topology import CameraTopology, Station, TransitEdge @@ -71,6 +81,8 @@ "ActivityState", "ActivityTimeline", "AssumedCalibration", + "Background", + "BackgroundSpec", "Box", "CalibrationDrift", "Cylinder", @@ -81,6 +93,7 @@ "CocoCategory", "CocoDataset", "CocoImage", + "DistractorSpec", "Entity", "EntityFrame", "EntityManifest", @@ -91,6 +104,8 @@ "HandOccluder", "InteractionEvent", "Intrinsics", + "Light", + "LightSpec", "Manifest", "MeshBackend", "NoiseModel", @@ -103,6 +118,9 @@ "PoseTrajectory", "PossessionSegment", "PossessionTimeline", + "RandomizationRecord", + "RandomizationSample", + "RandomizationSpec", "Scene", "SensorDropout", "Skeleton", diff --git a/src/multicam_sim/dsl/builder.py b/src/multicam_sim/dsl/builder.py index 4a2b305..fb41839 100644 --- a/src/multicam_sim/dsl/builder.py +++ b/src/multicam_sim/dsl/builder.py @@ -14,9 +14,10 @@ from ..entities import Entity, EntityFrame from ..occluders import OccluderUnion from ..possession import InteractionEvent, PossessionSegment, PossessionTimeline +from ..randomization import Background, Light, RandomizationRecord, RandomizationSpec from ..scene import Scene from .behavior import Behavior, PathBehavior -from .motion import PathUnion +from .motion import LinearPath, PathUnion from .occlusion import HandSweep, Occlusion Vec3 = tuple[float, float, float] @@ -72,6 +73,9 @@ def __init__(self, fps: float, num_frames: int) -> None: self._attachments: list[_AttachmentSpec] = [] self._interactions: list[InteractionEvent] = [] self._activity_segments: list[ActivitySegment] = [] + self._background: Background | None = None + self._light: Light | None = None + self._randomization: RandomizationRecord | None = None def cameras(self, cameras: list[Camera]) -> SceneBuilder: """Set the camera array (e.g. from :class:`multicam_sim.dsl.CameraRig`).""" @@ -114,6 +118,27 @@ def distractor( ) return self + def randomize(self, spec: RandomizationSpec, *, seed: int = 0) -> SceneBuilder: + """Sample ``spec`` under ``seed`` and apply the result to the scene. + + The builder does not own the randomness: :meth:`RandomizationSpec.sample` + draws one concrete sample (deterministic for a given spec + seed), and + this entry point applies it — the sampled ``background`` / ``light`` are + stored on the built :class:`Scene`, each sampled distractor position is + added as a static distractor through the existing :meth:`distractor` + entry point (id ``rand_distractor_{i}``), and a + :class:`~multicam_sim.randomization.RandomizationRecord` (spec + seed) + rides on the scene as provenance. Calling it again re-samples and + appends its distractors on top. + """ + sample = spec.sample(seed) + self._background = sample.background + self._light = sample.light + self._randomization = RandomizationRecord(spec=spec, seed=seed) + for i, position in enumerate(sample.distractor_positions): + self.distractor(f"rand_distractor_{i}", LinearPath(a=position, b=position)) + return self + def occlude(self, occlusion: Occlusion) -> SceneBuilder: """Add a declarative occlusion pattern (compiled to real geometry).""" self._occlusions.append(occlusion) @@ -360,4 +385,7 @@ def build(self) -> Scene: occluders=occluders, possession=possession, activity=activity, + background=self._background, + light=self._light, + randomization=self._randomization, ) diff --git a/src/multicam_sim/dsl/render.py b/src/multicam_sim/dsl/render.py index 90183aa..7af232b 100644 --- a/src/multicam_sim/dsl/render.py +++ b/src/multicam_sim/dsl/render.py @@ -64,7 +64,8 @@ def _build_pyrender_scene( pyrender, trimesh = _import_pyrender("The pyrender backend") cam = scene.cameras[camera_id] - pr_scene = pyrender.Scene(bg_color=[*bg, 1.0], ambient_light=[0.4, 0.4, 0.4]) + bg_rgb = scene.background.rgb if scene.background is not None else bg + pr_scene = pyrender.Scene(bg_color=[*bg_rgb, 1.0], ambient_light=[0.4, 0.4, 0.4]) # entity points -> small spheres at this frame's ground-truth coords for entity in scene.entities: @@ -92,10 +93,34 @@ def _build_pyrender_scene( pose[:3, 3] = cam.centre() pr_scene.add(pr_cam, pose=pose) if add_light: - pr_scene.add(pyrender.DirectionalLight(intensity=3.0), pose=pose) + # A sampled scene light overrides the fixed headlight (intensity 3.0 at + # the camera pose); without one the render is unchanged. + light = scene.light + light_intensity = light.intensity if light is not None else 3.0 + light_pose = _light_pose(pose, light.direction()) if light is not None else pose + pr_scene.add(pyrender.DirectionalLight(intensity=light_intensity), pose=light_pose) return pr_scene, intr +def _light_pose(camera_pose: FloatArray, direction: tuple[float, float, float]) -> FloatArray: + """Rotation aligning a directional light's local -Z with ``direction``. + + pyrender's ``DirectionalLight`` shines along its pose's local -Z (the same + axis the camera looks along), so the headlight default is just the camera + pose; a sampled world direction needs the pose's third column set to the + negated unit direction, with an orthonormal basis built around it. + """ + z_col = -np.asarray(direction, dtype=np.float64) + z_col /= np.linalg.norm(z_col) + ref = np.array([0.0, 0.0, 1.0]) if abs(z_col[2]) < 0.9 else np.array([1.0, 0.0, 0.0]) + x_col = np.cross(ref, z_col) + x_col /= np.linalg.norm(x_col) + y_col = np.cross(z_col, x_col) + pose = np.array(camera_pose, dtype=np.float64) + pose[:3, :3] = np.column_stack([x_col, y_col, z_col]) + return pose + + @runtime_checkable class RendererBackend(Protocol): """Anything that renders one camera's view of a scene at a frame to pixels. diff --git a/src/multicam_sim/scene.py b/src/multicam_sim/scene.py index 4a800d8..81952d6 100644 --- a/src/multicam_sim/scene.py +++ b/src/multicam_sim/scene.py @@ -11,6 +11,7 @@ from .entities import Entity from .occluders import OccluderUnion from .possession import PossessionTimeline +from .randomization import Background, Light, RandomizationRecord from .topology import CameraTopology @@ -29,6 +30,12 @@ class Scene(BaseModel): ``activity`` is an optional activity-state GT sidecar (see :mod:`multicam_sim.activity`) — same additive contract as ``possession``. + + ``background`` / ``light`` are optional render-time environment values and + ``randomization`` an optional provenance sidecar (spec + seed, see + :mod:`multicam_sim.randomization`). All three are additive — ``None`` by + default and never read by the manifest builder — so an un-randomized scene + is unchanged. """ fps: float @@ -39,6 +46,9 @@ class Scene(BaseModel): topology: CameraTopology | None = None possession: PossessionTimeline | None = None activity: ActivityTimeline | None = None + background: Background | None = None + light: Light | None = None + randomization: RandomizationRecord | None = None def model_post_init(self, __context: Any) -> None: if self.topology is None: From b608bd132efae41aa2dcda87acab704dd53912df Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 10:26:18 +0200 Subject: [PATCH 3/8] Test seeded randomization determinism and additivity (#41) Covers byte-identical sampling per seed, divergence across seeds, inverted- interval rejection, un-randomized scenes staying byte-identical (manifest and scene JSON), the distractor count knob flowing through the existing distractor path, and the provenance sidecar round-tripping and reproducing the sample. Co-Authored-By: Kimi K3 --- tests/test_randomization.py | 230 ++++++++++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/test_randomization.py diff --git a/tests/test_randomization.py b/tests/test_randomization.py new file mode 100644 index 0000000..bdd31dd --- /dev/null +++ b/tests/test_randomization.py @@ -0,0 +1,230 @@ +"""Seeded domain randomization for the Scene builder (#41). + +A typed ``RandomizationSpec`` (background / light / N distractors, each knob a +closed ``(min, max)`` interval) samples deterministically under a seed; the +``SceneBuilder.randomize`` entry point applies one sample — background and +light land on the :class:`Scene`, distractors go through the existing +``.distractor`` path, and a provenance sidecar (spec + seed) rides on the +scene. Everything is additive and off by default, so an un-randomized scene is +byte-identical to before. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from multicam_sim import ( + BackgroundSpec, + DistractorSpec, + LightSpec, + RandomizationSpec, + build_manifest, + build_smoke_scene, +) +from multicam_sim.dsl import CameraRig, SceneBuilder +from multicam_sim.dsl import Path as MotionPath +from multicam_sim.randomization import Light +from multicam_sim.scene import Scene + +_GOLDEN = Path(__file__).parent / "fixtures" / "manifest_golden" + + +def _spec(**overrides: object) -> RandomizationSpec: + """A spec with all three knobs active (defaults overridable).""" + knobs: dict[str, object] = { + "background": BackgroundSpec(), + "light": LightSpec(), + "distractors": DistractorSpec(count=(2, 2)), + } + knobs.update(overrides) + return RandomizationSpec(**knobs) # type: ignore[arg-type] + + +def _builder() -> SceneBuilder: + return ( + SceneBuilder(fps=10.0, num_frames=10) + .cameras( + CameraRig.ring( + n=2, + radius=5.0, + height=1.0, + look_at=(0.0, 0.0, 0.0), + focal=800.0, + width=640, + height_px=480, + ) + ) + .entity("obj", MotionPath.linear((0.0, -0.5, 0.0), (0.0, 0.5, 0.0))) + ) + + +def _same_shape(got: object, ref: object, path: str = "") -> None: + """Assert identical JSON structure (mirrors ``test_manifest_golden``).""" + assert type(got) is type(ref), f"type drift at {path or ''}: {type(got)} != {type(ref)}" + if isinstance(ref, dict): + assert isinstance(got, dict) + assert list(got) == list(ref), f"key set/order drift at {path or ''}" + for k in ref: + _same_shape(got[k], ref[k], f"{path}.{k}") + elif isinstance(ref, list): + assert isinstance(got, list) + assert len(got) == len(ref), f"length drift at {path or ''}: {len(got)} != {len(ref)}" + for i, (g, r) in enumerate(zip(got, ref, strict=True)): + _same_shape(g, r, f"{path}[{i}]") + elif isinstance(ref, float): + assert isinstance(got, float) + assert math.isclose(got, ref, rel_tol=1e-9, abs_tol=1e-12), ( + f"float drift at {path}: {got} != {ref}" + ) + else: + assert got == ref, f"value drift at {path}: {got!r} != {ref!r}" + + +def test_sample_is_byte_identical_for_same_seed() -> None: + spec = _spec() + first = spec.sample(42) + second = spec.sample(42) + assert first.model_dump_json() == second.model_dump_json() + + +def test_different_seeds_produce_different_samples() -> None: + spec = _spec() + assert spec.sample(42).model_dump_json() != spec.sample(7).model_dump_json() + + +def test_empty_spec_samples_to_empty_sample() -> None: + sample = RandomizationSpec().sample(0) + assert sample.background is None + assert sample.light is None + assert sample.distractor_positions == [] + + +@pytest.mark.parametrize( + "make", + [ + lambda: BackgroundSpec(rgb_min=(0.5, 0.0, 0.0), rgb_max=(0.4, 1.0, 1.0)), + lambda: BackgroundSpec(rgb_min=(0.0, 0.0, 1.2)), + lambda: LightSpec(intensity=(4.0, 2.0)), + lambda: LightSpec(intensity=(-1.0, 2.0)), + lambda: LightSpec(azimuth_deg=(180.0, 90.0)), + lambda: LightSpec(elevation_deg=(80.0, 20.0)), + lambda: LightSpec(elevation_deg=(0.0, 120.0)), + lambda: DistractorSpec(count=(3, 1)), + lambda: DistractorSpec(count=(-1, 2)), + lambda: DistractorSpec(x=(1.0, -1.0)), + ], +) +def test_inverted_or_invalid_interval_rejected(make: object) -> None: + with pytest.raises(ValidationError): + make() # type: ignore[operator] + + +def test_sampled_values_lie_within_the_intervals() -> None: + spec = _spec(distractors=DistractorSpec(count=(2, 4))) + for seed in range(10): + sample = spec.sample(seed) + assert sample.background is not None + assert all(0.0 <= c <= 0.2 for c in sample.background.rgb) + assert sample.light is not None + assert 2.0 <= sample.light.intensity <= 4.0 + assert 0.0 <= sample.light.azimuth_deg <= 360.0 + assert 30.0 <= sample.light.elevation_deg <= 90.0 + assert 2 <= len(sample.distractor_positions) <= 4 + for x, y, z in sample.distractor_positions: + assert -2.0 <= x <= 2.0 + assert -2.0 <= y <= 2.0 + assert 0.0 <= z <= 1.0 + + +def test_light_direction_is_unit_and_points_at_the_scene() -> None: + light = Light(intensity=3.0, azimuth_deg=53.25, elevation_deg=41.8) + direction = light.direction() + assert math.isclose(math.sqrt(sum(c * c for c in direction)), 1.0, rel_tol=1e-12) + # a light above the horizon shines downward + assert direction[2] < 0.0 + # azimuth 0 / elevation 0 shines along -X + assert Light(intensity=1.0, azimuth_deg=0.0, elevation_deg=0.0).direction() == pytest.approx( + (-1.0, 0.0, 0.0) + ) + + +def test_unrandomized_scene_is_byte_identical_to_before() -> None: + """No ``randomize`` call: no new Scene field is set, two independent builds + are byte-identical, and the golden smoke manifest is structurally unchanged + (the new fields never reach the manifest).""" + scene = _builder().build() + assert scene.background is None + assert scene.light is None + assert scene.randomization is None + + again = _builder().build() + assert scene.model_dump_json() == again.model_dump_json() + assert build_manifest(scene).to_json() == build_manifest(again).to_json() + + golden = json.loads((_GOLDEN / "smoke.json").read_text()) + _same_shape(json.loads(build_manifest(build_smoke_scene()).to_json()), golden) + + +def test_distractor_count_goes_through_the_existing_distractor_path() -> None: + spec = RandomizationSpec(distractors=DistractorSpec(count=(3, 3))) + scene = _builder().randomize(spec, seed=5).build() + + ids = [entity.id for entity in scene.entities] + assert ids == ["obj", "rand_distractor_0", "rand_distractor_1", "rand_distractor_2"] + + # distractors are static: every frame holds the same sampled position + sample = spec.sample(5) + for entity, position in zip(scene.entities[1:], sample.distractor_positions, strict=True): + for entity_frame in entity.frames: + assert entity_frame.points["center"] == pytest.approx(list(position)) + + # the existing distractor path means they appear as manifest entities + manifest = build_manifest(scene) + assert [entity.id for entity in manifest.entities] == ids + + +def test_zero_distractors_is_allowed() -> None: + spec = RandomizationSpec(distractors=DistractorSpec(count=(0, 0))) + scene = _builder().randomize(spec, seed=5).build() + assert [entity.id for entity in scene.entities] == ["obj"] + assert scene.randomization is not None + + +def test_randomized_scene_is_deterministic_per_seed() -> None: + spec = _spec() + first = _builder().randomize(spec, seed=11).build() + second = _builder().randomize(spec, seed=11).build() + other = _builder().randomize(spec, seed=12).build() + assert first.model_dump_json() == second.model_dump_json() + assert first.model_dump_json() != other.model_dump_json() + + +def test_background_and_light_are_applied_to_the_scene() -> None: + spec = _spec() + scene = _builder().randomize(spec, seed=3).build() + sample = spec.sample(3) + assert scene.background == sample.background + assert scene.light == sample.light + + +def test_provenance_sidecar_roundtrips_and_reproduces_the_scene() -> None: + spec = _spec() + scene = _builder().randomize(spec, seed=11).build() + record = scene.randomization + assert record is not None + assert record.seed == 11 + assert record.spec == spec + + # the sidecar survives JSON round-trip on the scene + restored = Scene.model_validate_json(scene.model_dump_json()) + assert restored.randomization == record + + # and the recorded spec + seed regenerate the exact applied sample + resampled = record.spec.sample(record.seed) + assert resampled.background == scene.background + assert resampled.light == scene.light From 31136c0332cc210b772967b2f59b7552364117f2 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 10:26:18 +0200 Subject: [PATCH 4/8] Document the randomization knobs and their ranges (#41) New DESIGN.md section under DSL assembly: what each knob means, its units, its default interval, and the same-spec-plus-same-seed reproducibility contract. Co-Authored-By: Kimi K3 --- DESIGN.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/DESIGN.md b/DESIGN.md index b1452ed..ddbde82 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -329,6 +329,52 @@ The result is the ordinary `Scene`; `build_manifest(scene)` is unchanged. A DSL scene that reproduces the smoke setup recovers ground truth for a cam-1-occluded frame through the real `triangulate_dlt`, exactly like the hand-built smoke. +### Domain randomization (optional, seeded — issue #41) + +Synthetic-to-real robustness needs varied scenes. A typed +`RandomizationSpec` (`multicam_sim.randomization`) attaches seeded +randomization knobs to the builder: `builder.randomize(spec, seed=...)` samples +the spec once (`RandomizationSpec.sample(seed)`, a pure function over +`numpy.random.default_rng(seed)` — the builder never owns the randomness) and +applies the concrete result. Every knob is a **closed `(min, max)` interval**, +validated at construction (an inverted `min > max` interval is rejected), and +every knob is **off by default** (`None` / empty), so a scene built without +randomization serialises byte-identically to before. + +The three knobs: + +- **`background`** (`BackgroundSpec`) — the render-time background colour. + `rgb_min` / `rgb_max` are RGB triples with channels in `[0, 1]` (the + renderer's colour convention), sampled per channel. Default interval + `(0, 0, 0)`–`(0.2, 0.2, 0.2)`: dark, around today's fixed black. +- **`light`** (`LightSpec`) — the key light. `intensity` is in renderer light + units (today's fixed headlight is `3.0`; default interval `(2.0, 4.0)`). + `azimuth_deg` / `elevation_deg` give the direction **from the scene to the + light** in **degrees** — azimuth in the world XY plane from +X toward +Y + (default `(0, 360)`), elevation above the XY plane (default `(30, 90)`, so + the light stays above the horizon); the light shines along the negated + vector (`Light.direction()`). +- **`distractors`** (`DistractorSpec`) — a count of static, non-target objects. + `count` is an **inclusive** integer interval (default `(1, 3)`); each + distractor's `x` / `y` / `z` (scene units) is drawn uniformly from its + interval (default a 4×4×1 box around the origin at floor level) and added + through the **existing** `SceneBuilder.distractor` entry point as + `rand_distractor_{i}` — so randomized distractors appear in the manifest + exactly like hand-added ones and never touch the primary entities' ground + truth. + +Sampling draws in a **fixed order** (background channels; light intensity, +azimuth, elevation; distractor count; then three coordinates per distractor), +so the **reproducibility contract** is: same spec + same seed → byte-identical +sample, and therefore a byte-identical scene. Provenance is recorded +additively: the built `Scene` carries the sampled `background` / `light` values +plus a `randomization` sidecar (`RandomizationRecord`, spec + seed) that +round-trips through scene JSON, so a randomized run regenerates from its own +output via `record.spec.sample(record.seed)`. None of these fields is read by +the manifest builder; the sampled background/light are consumed by the +pyrender backend (the Kubric backend keeps its fixed key light — see the +renderer section). + ## Renderer backend (`multicam_sim.dsl.render`) — not the contract `RendererBackend` is a `Protocol` (Scene + camera + frame → `(H,W,3)` pixels). From a35847ceff10e0bbf6bf6d0828a2b21be6701567 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 11:07:34 +0200 Subject: [PATCH 5/8] Keep rand_distractor ids unique across randomize calls (#41) A second .randomize(...) call restarted distractor numbering at 0, producing duplicate entity ids in the scene and manifest and silently overwriting the first batch in build()'s frames_by_id. Ids now come from a per-builder counter, so a single call keeps the stable rand_distractor_0..N-1 names and repeat calls cannot collide. Co-Authored-By: Kimi K3 --- src/multicam_sim/dsl/builder.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/multicam_sim/dsl/builder.py b/src/multicam_sim/dsl/builder.py index fb41839..21ce193 100644 --- a/src/multicam_sim/dsl/builder.py +++ b/src/multicam_sim/dsl/builder.py @@ -76,6 +76,7 @@ def __init__(self, fps: float, num_frames: int) -> None: self._background: Background | None = None self._light: Light | None = None self._randomization: RandomizationRecord | None = None + self._rand_distractors = 0 def cameras(self, cameras: list[Camera]) -> SceneBuilder: """Set the camera array (e.g. from :class:`multicam_sim.dsl.CameraRig`).""" @@ -129,14 +130,19 @@ def randomize(self, spec: RandomizationSpec, *, seed: int = 0) -> SceneBuilder: entry point (id ``rand_distractor_{i}``), and a :class:`~multicam_sim.randomization.RandomizationRecord` (spec + seed) rides on the scene as provenance. Calling it again re-samples and - appends its distractors on top. + appends its distractors on top; ids come from a per-builder counter so + a second call can never collide with the first. """ sample = spec.sample(seed) self._background = sample.background self._light = sample.light self._randomization = RandomizationRecord(spec=spec, seed=seed) - for i, position in enumerate(sample.distractor_positions): - self.distractor(f"rand_distractor_{i}", LinearPath(a=position, b=position)) + for position in sample.distractor_positions: + self.distractor( + f"rand_distractor_{self._rand_distractors}", + LinearPath(a=position, b=position), + ) + self._rand_distractors += 1 return self def occlude(self, occlusion: Occlusion) -> SceneBuilder: From e500e8815a4aabb9f00025e0a83112ea547a5393 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 11:07:57 +0200 Subject: [PATCH 6/8] Reject non-finite randomization bounds at construction (#41) NaN bounds slip past every min > max comparison and then crashed inside sample() with numpy's OverflowError. The shared interval check now rejects non-finite bounds, so LightSpec/DistractorSpec fail fast with a ValidationError naming the field (BackgroundSpec's channel range already rejected them). Co-Authored-By: Kimi K3 --- src/multicam_sim/randomization.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/multicam_sim/randomization.py b/src/multicam_sim/randomization.py index e165713..a106ba3 100644 --- a/src/multicam_sim/randomization.py +++ b/src/multicam_sim/randomization.py @@ -36,7 +36,9 @@ def _check_interval(name: str, lo: float, hi: float) -> None: - """Reject an inverted closed interval (the shared ``min > max`` check).""" + """Reject non-finite bounds and an inverted closed interval (``min > max``).""" + if not (math.isfinite(lo) and math.isfinite(hi)): + raise ValueError(f"{name}: bounds must be finite; got ({lo}, {hi})") if lo > hi: raise ValueError(f"{name}: min ({lo}) must be <= max ({hi})") From e6d277a24903421153a334cf8568340f1bf554ea Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 11:08:16 +0200 Subject: [PATCH 7/8] Correct background docs: it was already renderer-configurable (#41) PyrenderBackend(bg=...) already made the background configurable per renderer; the new knob is scene-level, randomizable control that takes precedence over that constructor default. Say so in DESIGN.md and the BackgroundSpec docstring, and document the precedence at the override point in render.py. Co-Authored-By: Kimi K3 --- DESIGN.md | 6 +++++- src/multicam_sim/dsl/render.py | 2 ++ src/multicam_sim/randomization.py | 5 ++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ddbde82..e10a9e4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -346,7 +346,11 @@ The three knobs: - **`background`** (`BackgroundSpec`) — the render-time background colour. `rgb_min` / `rgb_max` are RGB triples with channels in `[0, 1]` (the renderer's colour convention), sampled per channel. Default interval - `(0, 0, 0)`–`(0.2, 0.2, 0.2)`: dark, around today's fixed black. + `(0, 0, 0)`–`(0.2, 0.2, 0.2)`: dark, around the default black. Background + was already configurable per renderer through the `PyrenderBackend(bg=...)` + constructor argument; this knob adds **scene-level, randomizable** control — + the sampled value is recorded on the `Scene` and **takes precedence over the + constructor `bg`** wherever the scene carries one. - **`light`** (`LightSpec`) — the key light. `intensity` is in renderer light units (today's fixed headlight is `3.0`; default interval `(2.0, 4.0)`). `azimuth_deg` / `elevation_deg` give the direction **from the scene to the diff --git a/src/multicam_sim/dsl/render.py b/src/multicam_sim/dsl/render.py index 7af232b..bb7a211 100644 --- a/src/multicam_sim/dsl/render.py +++ b/src/multicam_sim/dsl/render.py @@ -64,6 +64,8 @@ def _build_pyrender_scene( pyrender, trimesh = _import_pyrender("The pyrender backend") cam = scene.cameras[camera_id] + # Precedence: a scene-level sampled background wins over the renderer's + # constructor ``bg`` (the more specific value beats the renderer default). bg_rgb = scene.background.rgb if scene.background is not None else bg pr_scene = pyrender.Scene(bg_color=[*bg_rgb, 1.0], ambient_light=[0.4, 0.4, 0.4]) diff --git a/src/multicam_sim/randomization.py b/src/multicam_sim/randomization.py index a106ba3..742b208 100644 --- a/src/multicam_sim/randomization.py +++ b/src/multicam_sim/randomization.py @@ -48,7 +48,10 @@ class BackgroundSpec(BaseModel): Channels are in ``[0, 1]`` (the renderer's colour convention, e.g. ``pyrender.Scene(bg_color=...)``). The default interval stays dark, around - today's fixed black background. + the default black background. Background was already configurable per + renderer via ``PyrenderBackend(bg=...)``; this knob makes it a scene-level, + randomizable value (recorded on the scene) that takes precedence over that + constructor default when set. """ model_config = ConfigDict(frozen=True) From 90fd87830cfb9873e6ec70a37d8260a60f353251 Mon Sep 17 00:00:00 2001 From: Nitjsefnie Date: Fri, 31 Jul 2026 11:08:16 +0200 Subject: [PATCH 8/8] Pin the shipped sample with a golden test; cover id uniqueness and NaN (#41) test_golden_sample_pins_the_shipped_draw_order asserts the sampled values for one fixed spec and seed against pasted-in numeric literals, so a change to draw order or seed handling goes red even though sampling stays deterministic (verified: both the reordered-draws and default_rng(seed+1) mutations fail it). Also covers unique ids across repeated randomize calls and NaN/inf rejection for every spec. Co-Authored-By: Kimi K3 --- tests/test_randomization.py | 72 +++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/test_randomization.py b/tests/test_randomization.py index bdd31dd..7368666 100644 --- a/tests/test_randomization.py +++ b/tests/test_randomization.py @@ -228,3 +228,75 @@ def test_provenance_sidecar_roundtrips_and_reproduces_the_scene() -> None: resampled = record.spec.sample(record.seed) assert resampled.background == scene.background assert resampled.light == scene.light + + +def test_golden_sample_pins_the_shipped_draw_order() -> None: + """The literals below are an independent oracle: pasted-in floats the + shipped implementation produces for this exact spec and seed. They are NOT + computed from ``sample()`` or derived from any production constant, so any + change to the draw order, the seed handling, or the distributions turns + this test red even though sampling stays deterministic.""" + spec = RandomizationSpec( + background=BackgroundSpec(rgb_min=(0.0, 0.0, 0.0), rgb_max=(0.2, 0.2, 0.2)), + light=LightSpec(intensity=(2.0, 4.0), azimuth_deg=(0.0, 360.0), elevation_deg=(30.0, 90.0)), + distractors=DistractorSpec(count=(2, 2), x=(-2.0, 2.0), y=(-2.0, 2.0), z=(0.0, 1.0)), + ) + sample = spec.sample(42) + + assert sample.background is not None + assert sample.background.rgb == pytest.approx( + (0.15479120971119267, 0.08777568795041046, 0.1717195839822765), rel=1e-12 + ) + assert sample.light is not None + assert sample.light.intensity == pytest.approx(3.3947360581187276, rel=1e-12) + assert sample.light.azimuth_deg == pytest.approx(33.90384523955383, rel=1e-12) + assert sample.light.elevation_deg == pytest.approx(88.53734109820536, rel=1e-12) + assert len(sample.distractor_positions) == 2 + assert sample.distractor_positions[0] == pytest.approx( + (1.0445588079614119, 1.1442572211078152, 0.12811363267554587), rel=1e-12 + ) + assert sample.distractor_positions[1] == pytest.approx( + (-0.19845624841773146, -0.5168079030696751, 0.9267649888486018), rel=1e-12 + ) + + +def test_repeated_randomize_never_collides_distractor_ids() -> None: + spec = RandomizationSpec(distractors=DistractorSpec(count=(2, 2))) + scene = _builder().randomize(spec, seed=1).randomize(spec, seed=2).build() + + ids = [entity.id for entity in scene.entities] + assert len(ids) == len(set(ids)), f"duplicate entity ids: {ids}" + assert ids == [ + "obj", + "rand_distractor_0", + "rand_distractor_1", + "rand_distractor_2", + "rand_distractor_3", + ] + # id-keyed lookups and the manifest agree with the entity list + manifest = build_manifest(scene) + assert [entity.id for entity in manifest.entities] == ids + + +@pytest.mark.parametrize( + "make", + [ + lambda: BackgroundSpec(rgb_min=(float("nan"), 0.0, 0.0)), + lambda: BackgroundSpec(rgb_max=(0.0, float("inf"), 0.0)), + lambda: BackgroundSpec(rgb_max=(0.0, float("-inf"), 0.0)), + lambda: LightSpec(intensity=(float("nan"), 4.0)), + lambda: LightSpec(intensity=(float("-inf"), 4.0)), + lambda: LightSpec(intensity=(2.0, float("inf"))), + lambda: LightSpec(azimuth_deg=(float("nan"), float("nan"))), + lambda: LightSpec(azimuth_deg=(0.0, float("inf"))), + lambda: LightSpec(elevation_deg=(float("nan"), 45.0)), + lambda: DistractorSpec(x=(float("nan"), 1.0)), + lambda: DistractorSpec(y=(float("-inf"), 1.0)), + lambda: DistractorSpec(z=(0.0, float("inf"))), + ], +) +def test_non_finite_bounds_rejected_at_construction(make: object) -> None: + """NaN and +/-inf must fail as a ValidationError naming the field, not as + a numpy OverflowError deep inside ``sample()``.""" + with pytest.raises(ValidationError): + make() # type: ignore[operator]