Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/multicam_sim/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,40 @@ def to_json(self) -> str:
optional fields omitted, full float precision, strict (finite) JSON."""
return self.model_dump_json(indent=2, exclude_none=True)

def coverage_summary(self) -> dict[int, tuple[float, float]]:
"""Per-camera coverage across all (entity, frame, point) observations.

Maps each camera id to ``(visible_fraction, mean_occ_frac)``:

- ``visible_fraction`` — fraction of that camera's observations with
``visible == True``, in ``[0, 1]`` (``0.0`` if it has no observations).
- ``mean_occ_frac`` — mean of the non-``None`` ``occ_frac`` values
(``0.0`` when none are recorded).

Pure read-only aggregation; numpy-free.
"""
counts: dict[int, int] = {}
visible_counts: dict[int, int] = {}
occ_sums: dict[int, float] = {}
occ_counts: dict[int, int] = {}
for entity in self.entities:
for frame in entity.frames:
for point in frame.points.values():
for obs in point.per_cam:
counts[obs.cam] = counts.get(obs.cam, 0) + 1
if obs.visible:
visible_counts[obs.cam] = visible_counts.get(obs.cam, 0) + 1
if obs.occ_frac is not None:
occ_sums[obs.cam] = occ_sums.get(obs.cam, 0.0) + obs.occ_frac
occ_counts[obs.cam] = occ_counts.get(obs.cam, 0) + 1
summary: dict[int, tuple[float, float]] = {}
for cam, total in counts.items():
visible_fraction = visible_counts.get(cam, 0) / total
occ_n = occ_counts.get(cam, 0)
mean_occ = occ_sums.get(cam, 0.0) / occ_n if occ_n else 0.0
summary[cam] = (visible_fraction, mean_occ)
return summary


# --------------------------------------------------------------------------- #
# Compute (pure projection + boolean visibility; no renderer).
Expand Down
27 changes: 27 additions & 0 deletions tests/test_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,30 @@ def test_no_occluders_occ_frac_is_zero() -> None:
camera = scene.cameras[0]
point = np.array([0.0, 0.0, 0.5], dtype=np.float64)
assert occlusion_fraction(camera, point, [], sample_count=27, jitter=0.05) == 0.0


def test_coverage_summary_flags_the_occluded_camera() -> None:
manifest = build_manifest(build_smoke_scene())
summary = manifest.coverage_summary()

# One entry per camera in the manifest.
assert set(summary) == {cam.id for cam in manifest.cameras}

# All visible fractions are in [0, 1].
for visible_fraction, mean_occ in summary.values():
assert 0.0 <= visible_fraction <= 1.0
assert mean_occ >= 0.0

# The most-occluded camera (highest mean occ_frac) has the lowest visible
# fraction — the whole point of the summary.
most_occluded = max(summary, key=lambda cam: summary[cam][1])
lowest_visible = min(summary, key=lambda cam: summary[cam][0])
assert most_occluded == lowest_visible


def test_coverage_summary_mean_occ_ignores_none() -> None:
# Without object_radius the silhouette labels are None, but occ_frac is
# always recorded, so mean_occ_frac is a real average, never NaN.
manifest = build_manifest(build_smoke_scene())
for _visible_fraction, mean_occ in manifest.coverage_summary().values():
assert mean_occ == mean_occ # not NaN
Loading