Skip to content

Commit 4ae4e8d

Browse files
authored
feat: add seconds-based Occlusion.during_seconds() schedule (#19)
Adds a seconds-based occlusion schedule alongside the frame-based .during(). Closes #15.
1 parent 4af3768 commit 4ae4e8d

3 files changed

Lines changed: 73 additions & 0 deletions

File tree

src/multicam_sim/dsl/builder.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ def build(self) -> Scene:
7878

7979
occluders: list[OccluderUnion] = []
8080
for occ in self._occlusions:
81+
if occ.frames is not None and occ.seconds is not None:
82+
raise ValueError("occlusion has both frames and seconds windows; use one schedule")
83+
if occ.seconds is not None:
84+
t0, t1 = occ.seconds
85+
f0 = int(round(t0 * self.fps))
86+
f1 = int(round(t1 * self.fps))
87+
occ = occ.model_copy(update={"frames": (f0, f1), "seconds": None})
8188
target_id = occ.entity if occ.entity is not None else self._entities[0].id
8289
if target_id not in frames_by_id:
8390
raise ValueError(f"occlusion targets unknown entity {target_id!r}")

src/multicam_sim/dsl/occlusion.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ class Occlusion(BaseModel):
4949
offset: float = 0.15 # fraction from the point toward the camera centre
5050
camera: int | None = None
5151
frames: tuple[int, int] | None = None
52+
seconds: tuple[float, float] | None = None
5253
entity: str | None = None # default: the scene's first entity
5354
point_name: str = "center"
5455

@@ -81,11 +82,25 @@ def blocks(self, camera: int) -> Occlusion:
8182

8283
def during(self, frames: tuple[int, int]) -> Occlusion:
8384
"""Aim the occlusion at the frame window ``(frame0, frame1)`` inclusive."""
85+
if self.seconds is not None:
86+
raise ValueError("occlusion already has a seconds window; use .during_seconds(...)")
8487
f0, f1 = frames
8588
if f0 > f1:
8689
raise ValueError("during(frames): frame0 must be <= frame1")
8790
return self.model_copy(update={"frames": frames})
8891

92+
def during_seconds(self, t0: float, t1: float) -> Occlusion:
93+
"""Aim the occlusion at the seconds window ``(t0, t1)`` inclusive.
94+
95+
The window is converted to frames by :meth:`SceneBuilder.build` using the
96+
scene ``fps``, rounding to the nearest frame.
97+
"""
98+
if self.frames is not None:
99+
raise ValueError("occlusion already has a frames window; use .during(...)")
100+
if t0 > t1:
101+
raise ValueError("during_seconds(t0, t1): t0 must be <= t1")
102+
return self.model_copy(update={"seconds": (t0, t1)})
103+
89104
def on(self, entity: str, point_name: str = "center") -> Occlusion:
90105
"""Target a named entity/point (default: first entity, point ``center``)."""
91106
return self.model_copy(update={"entity": entity, "point_name": point_name})

tests/test_dsl_scene.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,54 @@ def mid_occ_frac(coverage: float) -> float:
172172
fracs = [mid_occ_frac(c) for c in (0.5, 1.0, 2.0)]
173173
assert fracs == sorted(fracs) # monotonic non-decreasing
174174
assert fracs[-1] > 0.0
175+
176+
177+
def _scene_with_occlusion(occ: Occlusion, *, num_frames: int = 50) -> dict:
178+
scene = (
179+
SceneBuilder(fps=30.0, num_frames=num_frames)
180+
.cameras(
181+
CameraRig.ring(
182+
n=3,
183+
radius=4.0,
184+
height=1.5,
185+
look_at=(0.0, 0.0, 0.5),
186+
focal=800.0,
187+
width=640,
188+
height_px=480,
189+
)
190+
)
191+
.entity("obj", Path.linear((0.0, -0.6, 0.5), (0.0, 0.6, 0.5)))
192+
.occlude(occ)
193+
.build()
194+
)
195+
return build_manifest(scene)
196+
197+
198+
def test_during_seconds_matches_during_frames() -> None:
199+
"""seconds window (0.5, 1.5) @ 30fps rounds to frames (15, 45) and produces
200+
the identical per-frame visible pattern for the occluded point."""
201+
by_frames = _scene_with_occlusion(Occlusion.sphere(size=0.15).blocks(camera=1).during((15, 45)))
202+
by_seconds = _scene_with_occlusion(
203+
Occlusion.sphere(size=0.15).blocks(camera=1).during_seconds(0.5, 1.5)
204+
)
205+
vis_frames = [
206+
fr["points"]["center"]["per_cam"][1]["visible"] for fr in by_frames["entities"][0]["frames"]
207+
]
208+
vis_seconds = [
209+
fr["points"]["center"]["per_cam"][1]["visible"]
210+
for fr in by_seconds["entities"][0]["frames"]
211+
]
212+
assert vis_frames == vis_seconds
213+
214+
215+
def test_during_seconds_rejects_inverted_window() -> None:
216+
with pytest.raises(ValueError):
217+
Occlusion.sphere(size=0.15).during_seconds(1.5, 0.5)
218+
219+
220+
def test_occlusion_rejects_both_frames_and_seconds_windows() -> None:
221+
"""An occlusion must declare exactly one schedule: frames or seconds."""
222+
with pytest.raises(ValueError):
223+
Occlusion.sphere(size=0.15).during((3, 7)).during_seconds(0.5, 1.5)
224+
with pytest.raises(ValueError):
225+
Occlusion.sphere(size=0.15).during_seconds(0.5, 1.5).during((3, 7))

0 commit comments

Comments
 (0)