From 2357ef85f0f0cc0ac82165dcdb66267a7222d0b6 Mon Sep 17 00:00:00 2001 From: dipakchaudhari12717 Date: Tue, 21 Jul 2026 21:53:21 +0530 Subject: [PATCH] feat(manifest): add Manifest.coverage_summary() After the occlusion labels landed, seeing which camera is most occluded in a manifest meant hand-rolling the loop. Add coverage_summary() returning per camera id (visible_fraction, mean_occ_frac): the fraction of that camera's (entity, frame, point) observations with visible=True, and the mean of the non-None occ_frac values. Pure read-only aggregation, numpy-free. Fixes #53 --- src/multicam_sim/manifest.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_manifest.py | 27 +++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/multicam_sim/manifest.py b/src/multicam_sim/manifest.py index d691004..ffd74d5 100644 --- a/src/multicam_sim/manifest.py +++ b/src/multicam_sim/manifest.py @@ -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). diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 1e51a22..98d7589 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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