diff --git a/prepare_ego4d_av_eot_int_review.py b/prepare_ego4d_av_eot_int_review.py index df1cb2b..d49ca96 100644 --- a/prepare_ego4d_av_eot_int_review.py +++ b/prepare_ego4d_av_eot_int_review.py @@ -28,6 +28,7 @@ class CandidateConfig: relation_window_seconds: float = 3.0 same_speaker_pause_max_seconds: float = 2.0 other_handover_max_seconds: float = 1.5 + max_terminal_overrun_seconds: float = 0.1 @dataclass(frozen=True) @@ -296,14 +297,24 @@ def derive_clip_candidates( clip: dict[str, Any], *, split: str, config: CandidateConfig ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: segments, wearer_id = load_clip_segments(clip) - clip_end = _number(clip.get("clip_end_sec", max((row.end for row in segments), default=0.0)), field="clip_end_sec") - outside = [row for row in segments if row.end > clip_end + 1e-6] - if outside: - first = outside[0] + declared_clip_end = _number( + clip.get("clip_end_sec", max((row.end for row in segments), default=0.0)), + field="clip_end_sec", + ) + maximum_segment_end = max((row.end for row in segments), default=declared_clip_end) + terminal_overrun = max(0.0, maximum_segment_end - declared_clip_end) + if terminal_overrun > config.max_terminal_overrun_seconds + 1e-9: + first = next(row for row in segments if row.end > declared_clip_end) raise ValueError( f"{clip.get('clip_uid')}: voice segment {first.uid} ends at {first.end:.6f}s " - f"after clip_end_sec={clip_end:.6f}; refusing to clamp or drop it" + f"after clip_end_sec={declared_clip_end:.6f}; terminal overrun " + f"{terminal_overrun:.6f}s exceeds the audited limit " + f"{config.max_terminal_overrun_seconds:.6f}s; refusing to clamp or drop it" ) + # Ego4D v1 has quantized terminal voice endpoints a few milliseconds past + # the 30-fps video endpoint (for example 300.008 vs 299.966667 seconds). + # Preserve that official endpoint and make the bounded exception explicit. + clip_end = max(declared_clip_end, maximum_segment_end) candidates: list[dict[str, Any]] = [] wearer_segments = [row for row in segments if row.person_id == wearer_id] @@ -409,6 +420,14 @@ def derive_clip_candidates( )) control_sequence += 1 candidates.sort(key=lambda row: (float(row["event_time"]), str(row["uid"]))) + for row in candidates: + row["temporal_evidence"].update({ + "declared_clip_end_seconds": declared_clip_end, + "maximum_voice_segment_end_seconds": maximum_segment_end, + "terminal_voice_overrun_seconds": terminal_overrun, + "max_allowed_terminal_overrun_seconds": config.max_terminal_overrun_seconds, + "terminal_voice_endpoint_preserved_not_clamped": True, + }) return candidates, [_timeline_row(row) for row in segments] @@ -462,6 +481,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--relation-window-seconds", type=float, default=3.0) parser.add_argument("--same-speaker-pause-max-seconds", type=float, default=2.0) parser.add_argument("--other-handover-max-seconds", type=float, default=1.5) + parser.add_argument( + "--max-terminal-overrun-seconds", type=float, default=0.1, + help="Allow and audit a bounded official voice endpoint past clip_end_sec (default: %(default)s).", + ) parser.add_argument("--overwrite", action="store_true") return parser.parse_args() @@ -471,6 +494,7 @@ def main() -> None: numeric = [ args.context_seconds, args.relation_window_seconds, args.same_speaker_pause_max_seconds, args.other_handover_max_seconds, + args.max_terminal_overrun_seconds, ] if any(value < 0 for value in numeric): raise ValueError("all time windows must be non-negative") diff --git a/test_prepare_ego4d_av_eot_int_review.py b/test_prepare_ego4d_av_eot_int_review.py index d0cb454..a2662c2 100644 --- a/test_prepare_ego4d_av_eot_int_review.py +++ b/test_prepare_ego4d_av_eot_int_review.py @@ -114,6 +114,25 @@ class Ego4DAVCandidateTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "exactly one camera wearer"): load_clip_segments(clip) + def test_bounded_official_terminal_endpoint_is_preserved_and_audited(self) -> None: + clip = fixture_clip() + clip["clip_end_sec"] = 8.76 + rows, _ = derive_clip_candidates(clip, split="val", config=CandidateConfig()) + offset = next( + row for row in rows + if row["candidate_source"] == "WEARER_OFFSET" and row["event_time"] == 8.0 + ) + self.assertAlmostEqual( + offset["temporal_evidence"]["terminal_voice_overrun_seconds"], 0.04 + ) + + def test_large_terminal_endpoint_overrun_is_rejected(self) -> None: + clip = fixture_clip() + clip["clip_end_sec"] = 11.95 + clip["camera_wearer"]["voice_segments"].append(segment("wearer", 11.8, 12.2)) + with self.assertRaisesRegex(ValueError, "exceeds the audited limit"): + derive_clip_candidates(clip, split="val", config=CandidateConfig()) + if __name__ == "__main__": unittest.main()