diff --git a/docs/assets/handoff_ltr_multiview.gif b/docs/assets/handoff_ltr_multiview.gif new file mode 100644 index 0000000..8584ae4 Binary files /dev/null and b/docs/assets/handoff_ltr_multiview.gif differ diff --git a/docs/assets/handoff_ltr_multiview.mp4 b/docs/assets/handoff_ltr_multiview.mp4 new file mode 100644 index 0000000..e41664b Binary files /dev/null and b/docs/assets/handoff_ltr_multiview.mp4 differ diff --git a/scripts/view_scene_3d.py b/scripts/view_scene_3d.py new file mode 100644 index 0000000..476aad3 --- /dev/null +++ b/scripts/view_scene_3d.py @@ -0,0 +1,207 @@ +"""Render a scene manifest as one 3D world view: camera locations + trajectory. + +A single static 3D figure showing where every camera sits in the world (drawn as +a small view-cone / frustum pointing along its optical axis), a ground plane, and +each entity's ground-truth 3D trajectory over the frames. This is the "see the +space" view -- unlike the per-camera tiles of ``record_multiview.py``, it draws +the whole scene once so camera coverage and overlap are directly visible. + +Geometry follows ``multicam_sim.cameras.Camera``: ``R`` is the world->camera +rotation with rows ``[right, down, forward]`` (OpenCV, +z forward) and ``t`` is +the world->camera translation, so the camera centre is ``C = -R^T @ t`` and the +frustum edges are the back-projected image corners ``R^T @ (K^-1 @ [u, v, 1])``. +World up is +z; the ground plane is drawn at z = 0. + +Works on any manifest with the standard schema (``cameras[*].{K,R,t,width, +height}`` and ``entities[*].frames[*].points[*].xyz_gt``); defaults to the +bundled MTMC golden fixture. + +``matplotlib`` is imported lazily inside :func:`main` (it is not a package +dependency), mirroring the lazy-import pattern in ``scripts/record_multiview.py``. +Run:: + + uv run --with matplotlib python scripts/view_scene_3d.py + uv run --with matplotlib python scripts/view_scene_3d.py --manifest PATH --out PATH +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np + +if TYPE_CHECKING: + from numpy.typing import NDArray + +_ROOT = Path(__file__).resolve().parent.parent +_DEFAULT_MANIFEST = _ROOT / "tests" / "fixtures" / "manifest_golden" / "mtmc.json" +_DEFAULT_OUT = _ROOT / "docs" / "assets" / "scene_3d.png" + +# How far in front of each camera to draw the frustum apex (world units). +_FRUSTUM_DEPTH = 1.2 + + +def _camera_centre( + rotation: NDArray[np.float64], translation: NDArray[np.float64] +) -> NDArray[np.float64]: + """World-space camera centre ``C = -R^T @ t``.""" + return -rotation.T @ translation + + +def _frustum_corners( + intrinsics: NDArray[np.float64], + rotation: NDArray[np.float64], + centre: NDArray[np.float64], + width: float, + height: float, + depth: float, +) -> NDArray[np.float64]: + """The four image-corner rays back-projected to ``depth`` in world space. + + A pixel ``[u, v, 1]`` back-projects to the world ray direction + ``R^T @ (K^-1 @ [u, v, 1])``; scaling so the forward (+z camera) component + equals ``depth`` puts the corner a fixed distance in front of the camera. + """ + inv_k = np.linalg.inv(intrinsics) + corners_px = np.array( + [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]], + dtype=np.float64, + ) + out = np.empty((4, 3), dtype=np.float64) + for i, (u, v) in enumerate(corners_px): + cam_ray = inv_k @ np.array([u, v, 1.0], dtype=np.float64) + cam_ray = cam_ray / cam_ray[2] * depth # forward (+z) component == depth + out[i] = centre + rotation.T @ cam_ray + return out + + +def _load_manifest(path: Path) -> dict[str, Any]: + with path.open("r", encoding="utf-8") as fh: + data: dict[str, Any] = json.load(fh) + return data + + +def _trajectories(manifest: dict[str, Any]) -> list[tuple[str, NDArray[np.float64]]]: + """Per-(entity, point) ground-truth 3D polylines over the frames.""" + out: list[tuple[str, NDArray[np.float64]]] = [] + for entity in manifest.get("entities", []): + by_point: dict[str, list[list[float]]] = {} + for frame in entity.get("frames", []): + for name, point in frame.get("points", {}).items(): + xyz = point.get("xyz_gt") + if xyz is not None: + by_point.setdefault(name, []).append(list(xyz)) + for name, coords in by_point.items(): + label = entity.get("id", "object") + label = label if name == "center" else f"{label}:{name}" + out.append((str(label), np.asarray(coords, dtype=np.float64))) + return out + + +def render(manifest: dict[str, Any], out_path: Path) -> Path: + """Render the 3D scene view to ``out_path`` (PNG) and return the path.""" + import matplotlib + + matplotlib.use("Agg") # headless-safe: no display needed + import matplotlib.pyplot as plt + + fig = plt.figure(figsize=(9, 7)) + ax = fig.add_subplot(111, projection="3d") + + all_points: list[NDArray[np.float64]] = [] + + # Cameras: centre marker + a wireframe frustum to the four image corners. + for cam in manifest.get("cameras", []): + intrinsics = np.asarray(cam["K"], dtype=np.float64) + rotation = np.asarray(cam["R"], dtype=np.float64) + translation = np.asarray(cam["t"], dtype=np.float64) + centre = _camera_centre(rotation, translation) + corners = _frustum_corners( + intrinsics, + rotation, + centre, + float(cam["width"]), + float(cam["height"]), + _FRUSTUM_DEPTH, + ) + ax.scatter(*centre, color="crimson", s=40, depthshade=False) + ax.text(*centre, f" cam {cam['id']}", color="crimson", fontsize=8) + for corner in corners: + edge = np.vstack([centre, corner]) + ax.plot(edge[:, 0], edge[:, 1], edge[:, 2], color="crimson", linewidth=0.8) + loop = np.vstack([corners, corners[0]]) + ax.plot(loop[:, 0], loop[:, 1], loop[:, 2], color="crimson", linewidth=0.8) + all_points.append(centre[None, :]) + all_points.append(corners) + + # Trajectories: one polyline per entity point. + for label, coords in _trajectories(manifest): + ax.plot( + coords[:, 0], + coords[:, 1], + coords[:, 2], + marker="o", + markersize=3, + linewidth=1.5, + label=f"trajectory: {label}", + ) + all_points.append(coords) + + if not all_points: + raise ValueError("manifest has no cameras or trajectories to draw") + pts = np.vstack(all_points) + + # Ground plane at z = 0 spanning the scene's xy extent. + pad = 1.0 + x_min, y_min = pts[:, 0].min() - pad, pts[:, 1].min() - pad + x_max, y_max = pts[:, 0].max() + pad, pts[:, 1].max() + pad + gx, gy = np.meshgrid( + np.linspace(x_min, x_max, 2), + np.linspace(y_min, y_max, 2), + ) + ax.plot_surface(gx, gy, np.zeros_like(gx), alpha=0.12, color="steelblue") + + ax.set_xlabel("x") + ax.set_ylabel("y") + ax.set_zlabel("z (up)") + ax.set_title("Scene: camera locations and object trajectory") + ax.legend(loc="upper left", fontsize=8) + + # Equal aspect over the combined bounds so directions are not skewed. + span = pts.max(axis=0) - pts.min(axis=0) + span = np.where(span > 0, span, 1.0) + ax.set_box_aspect(tuple(span)) + + out_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_path, dpi=130, bbox_inches="tight") + plt.close(fig) + return out_path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=_DEFAULT_MANIFEST, + help="scene manifest JSON (default: bundled MTMC golden fixture)", + ) + parser.add_argument( + "--out", + type=Path, + default=_DEFAULT_OUT, + help="output PNG path (default: docs/assets/scene_3d.png)", + ) + args = parser.parse_args() + + manifest = _load_manifest(args.manifest) + out_path = render(manifest, args.out) + size = out_path.stat().st_size + print(f"wrote {out_path} ({size} bytes)") + + +if __name__ == "__main__": + main() diff --git a/src/multicam_sim/__init__.py b/src/multicam_sim/__init__.py index 581a86b..c4e7dd4 100644 --- a/src/multicam_sim/__init__.py +++ b/src/multicam_sim/__init__.py @@ -37,6 +37,7 @@ compute_group_membership, write_group_json, ) +from .handoff_ltr import build_handoff_ltr_scene from .manifest import ( AssumedCalibration, CameraManifest, @@ -52,6 +53,7 @@ from .noise import CalibrationDrift, NoiseModel, PixelNoise from .occluders import Box, Cylinder, HandKeyframe, HandOccluder, Occluder, PathOccluder, Sphere from .overlay import export_overlay +from .parcel_sort import build_parcel_sort_scene from .pose import ( COCO17_EDGES, COCO17_JOINTS, @@ -143,6 +145,8 @@ "YoloLabel", "build_action_ground_truth", "build_group_formation_scene", + "build_handoff_ltr_scene", + "build_parcel_sort_scene", "build_manifest", "build_mtmc_scene", "silhouette_visible_fraction", diff --git a/src/multicam_sim/handoff_ltr.py b/src/multicam_sim/handoff_ltr.py new file mode 100644 index 0000000..6a3f42f --- /dev/null +++ b/src/multicam_sim/handoff_ltr.py @@ -0,0 +1,126 @@ +"""Left-to-right handoff scene: a parcel crosses the space, camera to camera. + +A domain-neutral scenario built to show a moving parcel being handed off across +overlapping cameras as it travels in a straight line from left to right (world +``-x -> +x``): + + * a **left** camera frames the left of the space: the parcel starts fully in + its view and gradually leaves it as it moves right; + * two **right** cameras frame the middle and right of the space with + overlapping fields of view, so as the parcel leaves the left camera it + appears first in the middle-right camera and then, over an overlap band, in + both right cameras at once; + * a **wide** camera stands well back and frames the whole line, so it sees the + parcel on EVERY frame — the always-on reference view. + +The result is a graduated coverage hand-off recorded in the manifest's per-camera +``in_view`` flags with no schema change: the left camera's ``in_view`` falls from +true to false as the parcel exits, the two right cameras' rise as it enters (with +an overlap band where both are true), and the wide camera is true throughout. The +parcel keeps ONE stable ``entity.id``, the cross-camera identity a tracker must +preserve across the hand-off. +""" + +from __future__ import annotations + +from .cameras import Camera +from .dsl.motion import Path +from .dsl.rig import CameraRig, StationView +from .entities import Entity +from .scene import Scene +from .topology import CameraTopology, Station, TransitEdge + +_WIDTH = 1280 +_HEIGHT_PX = 720 +_FPS = 10.0 +_NUM_FRAMES = 40 + +#: The parcel travels this straight line, left to right, at torso height. +_START_X = -8.0 +_END_X = 8.0 +_PARCEL_Y = 0.0 +_PARCEL_Z = 1.0 + +#: Near cameras stand back off the line along -y, looking toward it. +_CAM_Y = -5.0 +_CAM_Z = 1.8 +#: The wide reference camera stands off the FAR RIGHT end of the line and looks +#: down its length toward the far left, so the whole path stays in its frustum. +_WIDE_CAM_X = 13.0 +_WIDE_CAM_Y = -6.0 +_WIDE_CAM_Z = 3.0 + +PARCEL_ID = "parcel-1" + +#: Camera ids, in declaration order. +CAM_LEFT = 0 +CAM_MID_RIGHT = 1 +CAM_RIGHT = 2 +CAM_WIDE = 3 + + +def _station_views() -> list[StationView]: + """Four cameras: left, middle-right, right, and a wide always-on reference. + + The three near cameras are aimed at successive x-regions of the line with a + moderate FOV so their coverage overlaps in bands; the wide camera sits far + back with a large FOV so the whole line stays inside its frustum every frame. + """ + return [ + # Left camera: frames the left of the line; the parcel starts here. + StationView(position=(-5.0, _CAM_Y, _CAM_Z), look_at=(-5.0, 0.0, _PARCEL_Z), fov_deg=70.0), + # Middle-right camera: frames the centre; picks the parcel up while it is + # still in the left camera (an overlap band, no blind gap), overlapping + # both neighbours. + StationView(position=(0.0, _CAM_Y, _CAM_Z), look_at=(0.0, 0.0, _PARCEL_Z), fov_deg=75.0), + # Right camera: frames the right end; overlaps the middle-right camera in + # a band, then holds the parcel to the end. + StationView(position=(6.0, _CAM_Y, _CAM_Z), look_at=(6.0, 0.0, _PARCEL_Z), fov_deg=60.0), + # Wide reference camera: stands off the FAR RIGHT end and looks down the + # length of the line toward the far left, so the whole left-to-right path + # runs away from it and stays in view every frame. + StationView( + position=(_WIDE_CAM_X, _WIDE_CAM_Y, _WIDE_CAM_Z), + look_at=(_START_X, 0.0, _PARCEL_Z), + fov_deg=90.0, + ), + ] + + +def _cameras() -> list[Camera]: + return CameraRig.stations(_station_views(), width=_WIDTH, height_px=_HEIGHT_PX) + + +def build_handoff_ltr_scene() -> Scene: + """Construct the deterministic left-to-right cross-camera handoff scene.""" + cameras = _cameras() + + start = (_START_X, _PARCEL_Y, _PARCEL_Z) + end = (_END_X, _PARCEL_Y, _PARCEL_Z) + frames = Path.linear(start, end).compile_frames(_FPS, _NUM_FRAMES, name="center") + entity = Entity(id=PARCEL_ID, frames=frames) + + # Topology: three near stations left -> middle-right -> right, plus the wide + # reference station. Directed transit edges chain the left-to-right hand-off. + transit_s = _NUM_FRAMES / _FPS / 3.0 + topology = CameraTopology( + stations=[ + Station(id="left", camera_ids=[CAM_LEFT]), + Station(id="mid_right", camera_ids=[CAM_MID_RIGHT]), + Station(id="right", camera_ids=[CAM_RIGHT]), + Station(id="wide", camera_ids=[CAM_WIDE]), + ], + edges=[ + TransitEdge(src="left", dst="mid_right", transit_time_s=transit_s), + TransitEdge(src="mid_right", dst="right", transit_time_s=transit_s), + ], + ) + + return Scene( + fps=_FPS, + num_frames=_NUM_FRAMES, + cameras=cameras, + entities=[entity], + occluders=[], + topology=topology, + ) diff --git a/src/multicam_sim/parcel_sort.py b/src/multicam_sim/parcel_sort.py new file mode 100644 index 0000000..cd6392d --- /dev/null +++ b/src/multicam_sim/parcel_sort.py @@ -0,0 +1,149 @@ +"""Parcel-sort scene: a handler walks a line of sorting stations under overlapping cameras. + +A domain-neutral counterpart to :mod:`multicam_sim.mtmc`. Where MTMC places +cameras at SEPARATED stations with *disjoint* fields of view (an object is in at +most one view at a time, with a genuine blind gap between them), this scene +deliberately *overlaps* adjacent cameras' coverage along a straight walkway: + + * five fixed **sorting stations** (``station_1`` .. ``station_5``) sit evenly + spaced along the line; + * one **handler** — a single moving point named ``center`` — walks the line + ``station_1 -> station_5``, pausing (a **dwell**, the sort operation) at each + station before moving on; + * five **cameras** stand back off the line, one framing each station, aimed so + that adjacent cameras' fields of view overlap over the walkway *between* + stations. + +The overlap is graduated, not universal: while the handler dwells at a station +it is in exactly one camera's view, and while it *transits* between two adjacent +stations it is in view on BOTH covering cameras at once. That two-camera transit +interval is the overlap this scene exists to demonstrate (issue #84) — the +manifest's per-camera ``in_view`` flags record it with no schema change, and a +consumer can triangulate the handler wherever two cameras cover it. + +The handler keeps ONE stable ``entity.id`` throughout, the cross-camera +ground-truth identity a tracker must preserve down the line. The scene carries a +:class:`CameraTopology` whose stations name each sorting station's covering +camera and whose directed :class:`TransitEdge` s chain the walk ``station_i -> +station_{i+1}`` (and back) with the transit time between them. + +v1 models the handler as a single point target ("center"), which keeps the +overlap proof unambiguous (one point in view on two cameras) and the manifest +compact; a skeletoned handler carrying a parcel is a deliberate future +extension, not a gap. +""" + +from __future__ import annotations + +from .cameras import Camera +from .dsl.motion import Path, PathUnion +from .dsl.rig import CameraRig, StationView +from .entities import Entity +from .scene import Scene +from .topology import CameraTopology, Station, TransitEdge + +_WIDTH = 1280 +_HEIGHT_PX = 720 +_FPS = 10.0 + +_NUM_STATIONS = 5 +#: Spacing between adjacent sorting stations along the walkway (world units, +x). +_STATION_STEP = 3.0 +#: Height of the tracked handler point (torso, world +z). +_HANDLER_Z = 1.0 +#: Cameras stand back off the line along -y, looking toward the walkway. +_CAM_Y = -4.0 +_CAM_Z = 2.2 +#: Wide-ish FOV so adjacent cameras' cones overlap over the walkway. +_CAM_FOV_DEG = 70.0 + +#: Seconds the handler walks between two adjacent stations. +_WALK_S = 0.5 +#: Seconds the handler dwells (pauses, the sort operation) at each station. +_DWELL_S = 0.4 + +HANDLER_ID = "handler-1" +#: Domain-neutral sorting-station ids down the line. +STATION_IDS = [f"station_{i + 1}" for i in range(_NUM_STATIONS)] + + +def _station_x(index: int) -> float: + """World ``x`` of sorting station ``index`` (0-based) along the walkway.""" + return index * _STATION_STEP + + +def _station_views() -> list[StationView]: + """One camera per sorting station, standing back off the line and framing that + station. The wide FOV makes adjacent cones overlap over the walkway between + stations, so a transiting handler is seen by two cameras at once.""" + return [ + StationView( + position=(_station_x(i), _CAM_Y, _CAM_Z), + look_at=(_station_x(i), 0.0, _HANDLER_Z), + fov_deg=_CAM_FOV_DEG, + ) + for i in range(_NUM_STATIONS) + ] + + +def _cameras() -> list[Camera]: + return CameraRig.stations(_station_views(), width=_WIDTH, height_px=_HEIGHT_PX) + + +def _handler_path() -> PathUnion: + """A timed walk ``station_1 -> station_5`` with a dwell at each station. + + Built from the motion DSL: a zero-length ``linear(p, p).over(dwell)`` segment + holds the handler in place for the dwell, chained by ``.then`` with a + ``linear(p_i, p_{i+1}).over(walk)`` transit segment for each leg. The dwell + segments make the handler's position constant across consecutive frames at + each station; the transit segments carry it (and its overlapping two-camera + coverage) between them. + """ + points = [(_station_x(i), 0.0, _HANDLER_Z) for i in range(_NUM_STATIONS)] + path: PathUnion = Path.linear(points[0], points[0]).over(_DWELL_S) + for i in range(_NUM_STATIONS - 1): + walk = Path.linear(points[i], points[i + 1]).over(_WALK_S) + dwell = Path.linear(points[i + 1], points[i + 1]).over(_DWELL_S) + path = path.then(walk).then(dwell) + return path + + +def _num_frames(path: PathUnion) -> int: + """Frame count covering the whole timed walk at :data:`_FPS`, inclusive.""" + return int(round(path.total_duration() * _FPS)) + 1 + + +def build_parcel_sort_scene() -> Scene: + """Construct the deterministic overlapping parcel-sort scene. + + Five cameras with overlapping coverage watch one handler walk five sorting + stations, dwelling at each. Returns a :class:`Scene` carrying the per-station + :class:`CameraTopology` and no occluders. + """ + cameras = _cameras() + + path = _handler_path() + num_frames = _num_frames(path) + frames = path.compile_frames(_FPS, num_frames, name="center") + handler = Entity(id=HANDLER_ID, frames=frames) + + # Each sorting station is covered by its own camera (same index); the walk + # chains station_i <-> station_{i+1} with the transit time between them. + transit_s = _WALK_S + stations = [Station(id=station_id, camera_ids=[i]) for i, station_id in enumerate(STATION_IDS)] + edges: list[TransitEdge] = [] + for i in range(_NUM_STATIONS - 1): + src, dst = STATION_IDS[i], STATION_IDS[i + 1] + edges.append(TransitEdge(src=src, dst=dst, transit_time_s=transit_s)) + edges.append(TransitEdge(src=dst, dst=src, transit_time_s=transit_s)) + topology = CameraTopology(stations=stations, edges=edges) + + return Scene( + fps=_FPS, + num_frames=num_frames, + cameras=cameras, + entities=[handler], + occluders=[], + topology=topology, + ) diff --git a/tests/fixtures/manifest_golden/parcel_sort.json b/tests/fixtures/manifest_golden/parcel_sort.json new file mode 100644 index 0000000..1bc30a3 --- /dev/null +++ b/tests/fixtures/manifest_golden/parcel_sort.json @@ -0,0 +1,2938 @@ +{ + "cameras": [ + { + "id": 0, + "K": [ + [ + 914.0147243149534, + 0.0, + 640.0 + ], + [ + 0.0, + 914.0147243149534, + 360.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "R": [ + [ + 1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -0.28734788556634544, + -0.9578262852211513 + ], + [ + 0.0, + 0.9578262852211513, + -0.28734788556634544 + ] + ], + "t": [ + 0.0, + 0.9578262852211512, + 4.463470489130565 + ], + "width": 1280, + "height": 720, + "convention": "opencv_rdf" + }, + { + "id": 1, + "K": [ + [ + 914.0147243149534, + 0.0, + 640.0 + ], + [ + 0.0, + 914.0147243149534, + 360.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "R": [ + [ + 1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -0.28734788556634544, + -0.9578262852211513 + ], + [ + 0.0, + 0.9578262852211513, + -0.28734788556634544 + ] + ], + "t": [ + -3.0, + 0.9578262852211512, + 4.463470489130565 + ], + "width": 1280, + "height": 720, + "convention": "opencv_rdf" + }, + { + "id": 2, + "K": [ + [ + 914.0147243149534, + 0.0, + 640.0 + ], + [ + 0.0, + 914.0147243149534, + 360.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "R": [ + [ + 1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -0.28734788556634544, + -0.9578262852211513 + ], + [ + 0.0, + 0.9578262852211513, + -0.28734788556634544 + ] + ], + "t": [ + -6.0, + 0.9578262852211512, + 4.463470489130565 + ], + "width": 1280, + "height": 720, + "convention": "opencv_rdf" + }, + { + "id": 3, + "K": [ + [ + 914.0147243149534, + 0.0, + 640.0 + ], + [ + 0.0, + 914.0147243149534, + 360.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "R": [ + [ + 1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -0.28734788556634544, + -0.9578262852211513 + ], + [ + 0.0, + 0.9578262852211513, + -0.28734788556634544 + ] + ], + "t": [ + -9.0, + 0.9578262852211512, + 4.463470489130565 + ], + "width": 1280, + "height": 720, + "convention": "opencv_rdf" + }, + { + "id": 4, + "K": [ + [ + 914.0147243149534, + 0.0, + 640.0 + ], + [ + 0.0, + 914.0147243149534, + 360.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "R": [ + [ + 1.0, + -0.0, + 0.0 + ], + [ + 0.0, + -0.28734788556634544, + -0.9578262852211513 + ], + [ + 0.0, + 0.9578262852211513, + -0.28734788556634544 + ] + ], + "t": [ + -12.0, + 0.9578262852211512, + 4.463470489130565 + ], + "width": 1280, + "height": 720, + "convention": "opencv_rdf" + } + ], + "fps": 10.0, + "num_frames": 41, + "entities": [ + { + "id": "handler-1", + "frames": [ + { + "frame": 0, + "points": { + "center": { + "xyz_gt": [ + 0.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + -16.600496021019993, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -673.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1329.8014880630594, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1986.4019840840797, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 1, + "points": { + "center": { + "xyz_gt": [ + 0.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + -16.600496021019993, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -673.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1329.8014880630594, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1986.4019840840797, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 2, + "points": { + "center": { + "xyz_gt": [ + 0.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + -16.600496021019993, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -673.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1329.8014880630594, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1986.4019840840797, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 3, + "points": { + "center": { + "xyz_gt": [ + 0.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + -16.600496021019993, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -673.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1329.8014880630594, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1986.4019840840797, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 4, + "points": { + "center": { + "xyz_gt": [ + 0.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + -16.600496021019993, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -673.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1329.8014880630594, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1986.4019840840797, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 5, + "points": { + "center": { + "xyz_gt": [ + 0.5999999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 771.3200992042039, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 114.71960318318396, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -541.880892837836, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1198.4813888588558, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1855.0818848798758, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 6, + "points": { + "center": { + "xyz_gt": [ + 1.1999999999999997, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 902.6401984084079, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 246.0397023873879, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -410.56079363363204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -1067.1612896546517, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1723.761785675672, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 7, + "points": { + "center": { + "xyz_gt": [ + 1.7999999999999996, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1033.9602976126118, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 377.3598015915918, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -279.2406944294281, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -935.8411904504477, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1592.441686471468, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 8, + "points": { + "center": { + "xyz_gt": [ + 2.4000000000000004, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1165.280396816816, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 508.6799007957961, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -147.92059522522393, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -804.5210912462436, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1461.1215872672637, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 9, + "points": { + "center": { + "xyz_gt": [ + 3.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -673.2009920420397, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1329.8014880630599, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 10, + "points": { + "center": { + "xyz_gt": [ + 3.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -673.2009920420397, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1329.8014880630599, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 11, + "points": { + "center": { + "xyz_gt": [ + 3.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -673.2009920420397, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1329.8014880630599, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 12, + "points": { + "center": { + "xyz_gt": [ + 3.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -673.2009920420397, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1329.8014880630599, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 13, + "points": { + "center": { + "xyz_gt": [ + 3.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 639.9999999999999, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -673.2009920420397, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1329.8014880630599, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 14, + "points": { + "center": { + "xyz_gt": [ + 3.599999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1427.9205952252235, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 771.3200992042036, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 114.71960318318365, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -541.880892837836, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1198.4813888588562, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 15, + "points": { + "center": { + "xyz_gt": [ + 4.199999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1559.2406944294275, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 902.6401984084076, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 246.03970238738768, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -410.56079363363193, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -1067.1612896546521, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 16, + "points": { + "center": { + "xyz_gt": [ + 4.800000000000001, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1690.5607936336319, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1033.9602976126123, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 377.35980159159215, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -279.2406944294275, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -935.8411904504477, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 17, + "points": { + "center": { + "xyz_gt": [ + 5.3999999999999995, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1821.8808928378355, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1165.2803968168157, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 508.67990079579573, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -147.92059522522388, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -804.521091246244, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 18, + "points": { + "center": { + "xyz_gt": [ + 6.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1296.60049602102, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 640.0, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -16.600496021019627, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -673.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 19, + "points": { + "center": { + "xyz_gt": [ + 6.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1296.60049602102, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 640.0, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -16.600496021019627, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -673.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 20, + "points": { + "center": { + "xyz_gt": [ + 6.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1296.60049602102, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 640.0, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -16.600496021019627, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -673.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 21, + "points": { + "center": { + "xyz_gt": [ + 6.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1296.60049602102, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 640.0, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -16.600496021019627, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -673.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 22, + "points": { + "center": { + "xyz_gt": [ + 6.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1296.60049602102, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 640.0, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + -16.600496021019627, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -673.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 23, + "points": { + "center": { + "xyz_gt": [ + 6.599999999999998, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2084.521091246243, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1427.9205952252235, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 771.3200992042034, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 114.71960318318375, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -541.8808928378364, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 24, + "points": { + "center": { + "xyz_gt": [ + 7.199999999999998, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2215.8411904504474, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1559.2406944294275, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 902.6401984084074, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 246.0397023873878, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -410.5607936336324, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 25, + "points": { + "center": { + "xyz_gt": [ + 7.799999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2347.1612896546517, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1690.5607936336316, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1033.9602976126118, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 377.35980159159203, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -279.2406944294281, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 26, + "points": { + "center": { + "xyz_gt": [ + 8.399999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2478.481388858855, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1821.8808928378355, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1165.2803968168155, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 508.67990079579585, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -147.9205952252243, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 27, + "points": { + "center": { + "xyz_gt": [ + 9.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1953.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 28, + "points": { + "center": { + "xyz_gt": [ + 9.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1953.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 29, + "points": { + "center": { + "xyz_gt": [ + 9.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1953.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 30, + "points": { + "center": { + "xyz_gt": [ + 9.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1953.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 31, + "points": { + "center": { + "xyz_gt": [ + 9.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 1953.2009920420398, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1296.6004960210198, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + -16.60049602102006, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 32, + "points": { + "center": { + "xyz_gt": [ + 9.600000000000001, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2741.121587267264, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2084.521091246244, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1427.9205952252244, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 771.3200992042046, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 114.7196031831844, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 33, + "points": { + "center": { + "xyz_gt": [ + 10.2, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 2872.4416864714676, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2215.841190450448, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1559.240694429428, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 902.6401984084082, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 246.03970238738802, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 34, + "points": { + "center": { + "xyz_gt": [ + 10.799999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3003.7617856756715, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2347.1612896546512, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1690.5607936336314, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1033.9602976126118, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 377.35980159159163, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 35, + "points": { + "center": { + "xyz_gt": [ + 11.399999999999999, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3135.0818848798754, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2478.481388858855, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1821.8808928378355, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1165.2803968168157, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 508.6799007957956, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 36, + "points": { + "center": { + "xyz_gt": [ + 12.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3266.40198408408, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1296.6004960210203, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 37, + "points": { + "center": { + "xyz_gt": [ + 12.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3266.40198408408, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1296.6004960210203, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 38, + "points": { + "center": { + "xyz_gt": [ + 12.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3266.40198408408, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1296.6004960210203, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 39, + "points": { + "center": { + "xyz_gt": [ + 12.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3266.40198408408, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1296.6004960210203, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + }, + { + "frame": 40, + "points": { + "center": { + "xyz_gt": [ + 12.0, + 0.0, + 1.0 + ], + "per_cam": [ + { + "cam": 0, + "uv": [ + 3266.40198408408, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 1, + "uv": [ + 2609.80148806306, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 2, + "uv": [ + 1953.20099204204, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 3, + "uv": [ + 1296.6004960210203, + 360.0 + ], + "in_view": false, + "visible": false, + "occ_frac": 0.0 + }, + { + "cam": 4, + "uv": [ + 640.0000000000001, + 360.0 + ], + "in_view": true, + "visible": true, + "occ_frac": 0.0 + } + ] + } + } + } + ] + } + ], + "topology": { + "stations": [ + { + "id": "station_1", + "camera_ids": [ + 0 + ] + }, + { + "id": "station_2", + "camera_ids": [ + 1 + ] + }, + { + "id": "station_3", + "camera_ids": [ + 2 + ] + }, + { + "id": "station_4", + "camera_ids": [ + 3 + ] + }, + { + "id": "station_5", + "camera_ids": [ + 4 + ] + } + ], + "edges": [ + { + "src": "station_1", + "dst": "station_2", + "transit_time_s": 0.5 + }, + { + "src": "station_2", + "dst": "station_1", + "transit_time_s": 0.5 + }, + { + "src": "station_2", + "dst": "station_3", + "transit_time_s": 0.5 + }, + { + "src": "station_3", + "dst": "station_2", + "transit_time_s": 0.5 + }, + { + "src": "station_3", + "dst": "station_4", + "transit_time_s": 0.5 + }, + { + "src": "station_4", + "dst": "station_3", + "transit_time_s": 0.5 + }, + { + "src": "station_4", + "dst": "station_5", + "transit_time_s": 0.5 + }, + { + "src": "station_5", + "dst": "station_4", + "transit_time_s": 0.5 + } + ] + } +} \ No newline at end of file diff --git a/tests/test_handoff_ltr.py b/tests/test_handoff_ltr.py new file mode 100644 index 0000000..48ebf44 --- /dev/null +++ b/tests/test_handoff_ltr.py @@ -0,0 +1,115 @@ +"""Left-to-right cross-camera handoff scene. + +One parcel crosses the space in a straight line (world ``-x -> +x``) under four +cameras: a LEFT camera that sees it first and loses it, two RIGHT cameras +(mid-right, right) that pick it up as it enters — with overlap bands where two +near cameras see it at once — and a WIDE camera off the far-right end, looking +down the length of the line toward the far left, that sees the parcel on EVERY +frame. Coverage is read straight off the manifest's per-camera ``in_view`` flags +(the sim's own projection/visibility), never recomputed here. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +from multicam_sim import build_handoff_ltr_scene, build_manifest, write_manifest +from multicam_sim.handoff_ltr import ( + CAM_LEFT, + CAM_MID_RIGHT, + CAM_RIGHT, + CAM_WIDE, + PARCEL_ID, +) + + +def _load(tmp_path: Path) -> dict: + scene = build_handoff_ltr_scene() + path = tmp_path / "handoff_ltr.json" + write_manifest(scene, path) + return json.loads(path.read_text()) + + +def _frames(manifest: dict) -> list[dict]: + return manifest["entities"][0]["frames"] + + +def _in_view(frame: dict) -> list[bool]: + return [o["in_view"] for o in frame["points"]["center"]["per_cam"]] + + +def test_deterministic_byte_stable_manifest() -> None: + """Two independent builds serialise to the exact same JSON bytes.""" + first = build_manifest(build_handoff_ltr_scene()).to_json() + second = build_manifest(build_handoff_ltr_scene()).to_json() + assert first == second + + +def test_manifest_is_strict_finite_json(tmp_path: Path) -> None: + """Every projected uv is finite (valid strict JSON, no Infinity/NaN).""" + manifest = _load(tmp_path) + for frame in _frames(manifest): + for obs in frame["points"]["center"]["per_cam"]: + assert all(math.isfinite(c) for c in obs["uv"]) + + +def test_parcel_moves_left_to_right(tmp_path: Path) -> None: + """The parcel's world x is monotonically non-decreasing across the frames.""" + manifest = _load(tmp_path) + xs = [f["points"]["center"]["xyz_gt"][0] for f in _frames(manifest)] + assert xs[0] < xs[-1], "parcel should travel toward +x" + assert all(b >= a for a, b in zip(xs[:-1], xs[1:], strict=True)) + + +def test_wide_camera_sees_parcel_every_frame(tmp_path: Path) -> None: + """The far-right wide camera (looking down the line to the far left) is the + always-on reference: the parcel is in_view on it on EVERY frame.""" + manifest = _load(tmp_path) + frames = _frames(manifest) + assert all(_in_view(f)[CAM_WIDE] for f in frames) + + +def test_left_camera_sees_then_loses_the_parcel(tmp_path: Path) -> None: + """The LEFT camera sees the parcel at the start and loses it once the parcel + has moved right (a true->false transition, and it does not come back).""" + manifest = _load(tmp_path) + seen = [_in_view(f)[CAM_LEFT] for f in _frames(manifest)] + assert seen[0], "left camera should see the parcel at the start" + assert not seen[-1], "left camera should have lost the parcel by the end" + # One contiguous seen-then-gone run: after the first drop it never returns. + last_seen = max(i for i, v in enumerate(seen) if v) + assert all(not v for v in seen[last_seen + 1 :]) + assert all(seen[: last_seen + 1]), "left coverage is one contiguous run" + + +def test_right_cameras_pick_the_parcel_up(tmp_path: Path) -> None: + """The two RIGHT cameras enter after the start (the parcel is NOT in either at + frame 0) and hold it at the end, and the right camera is the final holder.""" + manifest = _load(tmp_path) + frames = _frames(manifest) + first = _in_view(frames[0]) + last = _in_view(frames[-1]) + assert not first[CAM_MID_RIGHT] and not first[CAM_RIGHT], ( + "right cameras should not yet see the parcel at the start" + ) + assert last[CAM_RIGHT], "the right camera should hold the parcel at the end" + + +def test_handoff_has_overlap_and_no_near_camera_blind_gap(tmp_path: Path) -> None: + """The handoff is smooth: on some frames two NEAR cameras (left/mid/right, not + the wide reference) see the parcel at once (overlap bands), and there is never + a frame where NO near camera sees it (no blind gap in the near coverage).""" + manifest = _load(tmp_path) + near = (CAM_LEFT, CAM_MID_RIGHT, CAM_RIGHT) + near_counts = [sum(_in_view(f)[c] for c in near) for f in _frames(manifest)] + assert max(near_counts) >= 2, "expected an overlap band of >=2 near cameras" + assert min(near_counts) >= 1, "no near-camera blind gap along the path" + + +def test_stable_identity(tmp_path: Path) -> None: + """One parcel with a stable id across every frame.""" + manifest = _load(tmp_path) + assert len(manifest["entities"]) == 1 + assert manifest["entities"][0]["id"] == PARCEL_ID diff --git a/tests/test_manifest_golden.py b/tests/test_manifest_golden.py index 86092e9..0e7df60 100644 --- a/tests/test_manifest_golden.py +++ b/tests/test_manifest_golden.py @@ -21,7 +21,12 @@ import pytest -from multicam_sim import build_manifest, build_mtmc_scene, build_smoke_scene +from multicam_sim import ( + build_manifest, + build_mtmc_scene, + build_parcel_sort_scene, + build_smoke_scene, +) from multicam_sim.scene import Scene _FIXTURES = Path(__file__).parent / "fixtures" / "manifest_golden" @@ -67,6 +72,7 @@ def _same_shape(got: object, ref: object, path: str = "") -> None: ("smoke", build_smoke_scene), ("mtmc", build_mtmc_scene), ("assembly", _assembly_scene), + ("parcel_sort", build_parcel_sort_scene), ], ) def test_manifest_json_matches_golden(name: str, scene_factory: object) -> None: diff --git a/tests/test_parcel_sort.py b/tests/test_parcel_sort.py new file mode 100644 index 0000000..b8c8bc1 --- /dev/null +++ b/tests/test_parcel_sort.py @@ -0,0 +1,120 @@ +"""Parcel-sort overlapping-coverage scene. + +One handler walks a line of five sorting stations, dwelling at each, watched by +five cameras with deliberately OVERLAPPING fields of view. Exercises the +serialized contract end to end: build the scene, write the manifest (strict JSON, +``allow_nan=False``), reload it, and assert determinism, the down-the-line +topology, the per-station dwell, and — the point of the scene — that the handler +is ``in_view`` on at least two cameras at once during station-to-station +transits. Overlap is read straight off the manifest's per-camera ``in_view`` +flags (the sim's own projection/visibility), never recomputed here. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +from multicam_sim import build_manifest, build_parcel_sort_scene, write_manifest +from multicam_sim.parcel_sort import HANDLER_ID, STATION_IDS + + +def _load(tmp_path: Path) -> dict: + scene = build_parcel_sort_scene() + path = tmp_path / "parcel_sort.json" + write_manifest(scene, path) + return json.loads(path.read_text()) + + +def _frames(manifest: dict) -> list[dict]: + return manifest["entities"][0]["frames"] + + +def _in_view(frame: dict) -> list[bool]: + return [o["in_view"] for o in frame["points"]["center"]["per_cam"]] + + +def test_manifest_is_strict_finite_json(tmp_path: Path) -> None: + """Every uv is finite — behind/out-of-frame projections are sanitised, so the + manifest is valid strict JSON (no Infinity/NaN) for the consumer.""" + manifest = _load(tmp_path) + for frame in _frames(manifest): + for obs in frame["points"]["center"]["per_cam"]: + assert all(math.isfinite(c) for c in obs["uv"]) + + +def test_deterministic_byte_stable_manifest() -> None: + """The builder is deterministic: two independent builds serialise to the exact + same JSON bytes (no unseeded RNG anywhere in the scene).""" + first = build_manifest(build_parcel_sort_scene()).to_json() + second = build_manifest(build_parcel_sort_scene()).to_json() + assert first == second + + +def test_visible_implies_in_view(tmp_path: Path) -> None: + """visible == in_view AND unoccluded, so visible is never true where in_view + is false.""" + manifest = _load(tmp_path) + for frame in _frames(manifest): + for obs in frame["points"]["center"]["per_cam"]: + if obs["visible"]: + assert obs["in_view"] + + +def test_handler_in_view_on_two_cameras_simultaneously(tmp_path: Path) -> None: + """The overlap proof: on some frame(s) the handler is in_view on AT LEAST + two cameras at once. Those frames are the station-to-station transits, and the + two covering cameras are always an ADJACENT pair (graduated overlap down the + line, not every camera seeing everything).""" + manifest = _load(tmp_path) + frames = _frames(manifest) + coverage = [sum(_in_view(f)) for f in frames] + + two_cam = [i for i, c in enumerate(coverage) if c >= 2] + assert two_cam, "expected >=2-camera overlap frames during transits" + + # Every overlap frame is covered by exactly an adjacent pair of cameras. + for i in two_cam: + seen = [cam for cam, iv in enumerate(_in_view(frames[i])) if iv] + assert len(seen) == 2, f"frame {i}: expected an adjacent pair, saw {seen}" + assert seen[1] - seen[0] == 1, f"frame {i}: cameras {seen} are not adjacent" + + # It is genuine graduated overlap, not universal: no frame is seen by all cams. + assert max(coverage) < len(manifest["cameras"]) + # The handler is always seen by at least one camera (no blind gap here). + assert min(coverage) >= 1 + + +def test_handler_dwells_at_each_station(tmp_path: Path) -> None: + """The dwell (the sort operation): the handler holds a constant position across + consecutive frames, once per sorting-station x.""" + manifest = _load(tmp_path) + xs = [round(f["points"]["center"]["xyz_gt"][0], 6) for f in _frames(manifest)] + + held = [a for a, b in zip(xs[:-1], xs[1:], strict=True) if a == b] + assert held, "expected held (dwell) frames where the handler pauses" + + # A dwell happens at each of the five sorting-station x positions. + dwell_xs = {x for x, nxt in zip(xs[:-1], xs[1:], strict=True) if x == nxt} + assert len(dwell_xs) == len(STATION_IDS) + + +def test_stable_identity_and_line_topology(tmp_path: Path) -> None: + """One handler with a stable id across every frame; topology emitted with a + station per sorting station (its covering camera) and directed transit edges + chaining the line both ways.""" + manifest = _load(tmp_path) + assert len(manifest["entities"]) == 1 + assert manifest["entities"][0]["id"] == HANDLER_ID + + topo = manifest["topology"] + assert [s["id"] for s in topo["stations"]] == STATION_IDS + by_id = {s["id"]: s["camera_ids"] for s in topo["stations"]} + for i, station_id in enumerate(STATION_IDS): + assert by_id[station_id] == [i] + + pairs = {(e["src"], e["dst"]) for e in topo["edges"]} + for a, b in zip(STATION_IDS[:-1], STATION_IDS[1:], strict=True): + assert (a, b) in pairs and (b, a) in pairs + assert all(e["transit_time_s"] > 0 for e in topo["edges"])