diff --git a/docs/ego4d_av_llm_annotation_review.md b/docs/ego4d_av_llm_annotation_review.md index d3d24ea..71f6af2 100644 --- a/docs/ego4d_av_llm_annotation_review.md +++ b/docs/ego4d_av_llm_annotation_review.md @@ -55,6 +55,18 @@ handoffs are added as independently reviewable hard-negative proposals. Timing rules only select and describe candidates; they never assign semantic positive labels. +Official zero-duration voice records cannot define an activity interval. They +are never expanded to an invented duration: the generator skips them, reports +the count on stdout, and stores their complete source records under +`clip_audits` and each candidate's `temporal_evidence`. A negative-duration +record remains a hard error. + +Ego4D v1 also contains nine train clips whose official voice endpoint extends +past `clip_end_sec` by 0.42--1.03 seconds. The default audited ceiling is 1.1 +seconds. These endpoints are preserved and flagged rather than silently +clamped; larger overruns remain errors. A human should normally exclude a +semantic boundary beyond observable media as `DATA_RECORDING_BOUNDARY`. + ## 2. Inspect what will be uploaded The LLM client refuses to upload transcript excerpts without an explicit diff --git a/prepare_ego4d_av_eot_int_review.py b/prepare_ego4d_av_eot_int_review.py index d49ca96..095908e 100644 --- a/prepare_ego4d_av_eot_int_review.py +++ b/prepare_ego4d_av_eot_int_review.py @@ -28,7 +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 + max_terminal_overrun_seconds: float = 1.1 @dataclass(frozen=True) @@ -61,8 +61,8 @@ def _segment_times(row: dict[str, Any]) -> tuple[float, float]: end = _number(row["end_time_sec"], field="end_time_sec") else: raise ValueError("voice segment lacks clip-local start/end time") - if end <= start: - raise ValueError(f"voice segment must have positive duration: {start}..{end}") + if end < start: + raise ValueError(f"voice segment has negative duration: {start}..{end}") return start, end @@ -111,7 +111,9 @@ def _addressed_to_wearer( return any(overlaps) -def load_clip_segments(clip: dict[str, Any]) -> tuple[list[SpeechSegment], str]: +def load_clip_segments( + clip: dict[str, Any], *, audit_out: dict[str, Any] | None = None, +) -> tuple[list[SpeechSegment], str]: """Return validated clip-local speaker segments and the wearer person ID.""" clip_uid = str(clip.get("clip_uid", "")).strip() if not clip_uid: @@ -142,6 +144,7 @@ def load_clip_segments(clip: dict[str, Any]) -> tuple[list[SpeechSegment], str]: segments: list[SpeechSegment] = [] seen: set[tuple[str, float, float]] = set() + zero_duration: list[dict[str, Any]] = [] ordinal = 0 for person in people: person_id = _person_key(person.get("person_id")) @@ -158,6 +161,15 @@ def load_clip_segments(clip: dict[str, Any]) -> tuple[list[SpeechSegment], str]: if key in seen: continue seen.add(key) + if end == start: + zero_duration.append({ + "person_id": person_id, + "participant_role": "wearer" if person_id == wearer_id else "other", + "time_seconds": start, + "source": dict(source), + "policy": "ignored-zero-duration-artifact-not-expanded", + }) + continue segments.append( SpeechSegment( uid=f"{clip_uid}/speech/{ordinal:05d}", @@ -178,6 +190,12 @@ def load_clip_segments(clip: dict[str, Any]) -> tuple[list[SpeechSegment], str]: for left, right in zip(segments, segments[1:]): if left.person_id == right.person_id and left.start == right.start and left.end == right.end: raise ValueError(f"{clip_uid}: duplicate voice segment after normalization") + if audit_out is not None: + audit_out.update({ + "zero_duration_voice_segment_count": len(zero_duration), + "zero_duration_voice_segments": zero_duration, + "zero_duration_policy": "ignore-with-audit; never clamp or expand", + }) return segments, wearer_id @@ -294,9 +312,11 @@ def _base_candidate( def derive_clip_candidates( - clip: dict[str, Any], *, split: str, config: CandidateConfig + clip: dict[str, Any], *, split: str, config: CandidateConfig, + audit_out: dict[str, Any] | None = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - segments, wearer_id = load_clip_segments(clip) + clip_audit: dict[str, Any] = {} + segments, wearer_id = load_clip_segments(clip, audit_out=clip_audit) declared_clip_end = _number( clip.get("clip_end_sec", max((row.end for row in segments), default=0.0)), field="clip_end_sec", @@ -427,7 +447,20 @@ def derive_clip_candidates( "terminal_voice_overrun_seconds": terminal_overrun, "max_allowed_terminal_overrun_seconds": config.max_terminal_overrun_seconds, "terminal_voice_endpoint_preserved_not_clamped": True, + "zero_duration_voice_segment_count": clip_audit[ + "zero_duration_voice_segment_count" + ], + "zero_duration_voice_segments": clip_audit["zero_duration_voice_segments"], + "zero_duration_voice_policy": clip_audit["zero_duration_policy"], }) + clip_audit.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, + }) + if audit_out is not None: + audit_out.update(clip_audit) return candidates, [_timeline_row(row) for row in segments] @@ -482,7 +515,7 @@ def parse_args() -> argparse.Namespace: 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, + "--max-terminal-overrun-seconds", type=float, default=1.1, help="Allow and audit a bounded official voice endpoint past clip_end_sec (default: %(default)s).", ) parser.add_argument("--overwrite", action="store_true") @@ -516,10 +549,16 @@ def main() -> None: config = CandidateConfig(*numeric) rows: list[dict[str, Any]] = [] timelines: dict[str, list[dict[str, Any]]] = {} + clip_audits: dict[str, dict[str, Any]] = {} for clip, split in selected: - derived, timeline = derive_clip_candidates(clip, split=split, config=config) + audit: dict[str, Any] = {} + derived, timeline = derive_clip_candidates( + clip, split=split, config=config, audit_out=audit + ) rows.extend(derived) - timelines[str(clip["clip_uid"])] = timeline + clip_uid = str(clip["clip_uid"]) + timelines[clip_uid] = timeline + clip_audits[clip_uid] = audit if not rows: raise ValueError("selected annotations produced no candidates") missing_voice_count_by_clip = { @@ -545,11 +584,20 @@ def main() -> None: _write_jsonl(output, rows) _write_json( timeline_output, - {"schema_version": 1, "dataset": "ego4d_av", "sessions": timelines}, + { + "schema_version": 1, + "dataset": "ego4d_av", + "sessions": timelines, + "clip_audits": clip_audits, + }, ) counts = Counter(row["candidate_source"] for row in rows) print(f"clips={len(selected)} candidates={len(rows)} output={output}") print("candidate_sources=" + json.dumps(dict(sorted(counts.items())), ensure_ascii=False)) + print( + "zero_duration_voice_segments_ignored_with_audit=" + + str(sum(audit["zero_duration_voice_segment_count"] for audit in clip_audits.values())) + ) print(f"timelines={timeline_output}") diff --git a/test_prepare_ego4d_av_eot_int_review.py b/test_prepare_ego4d_av_eot_int_review.py index a2662c2..6927478 100644 --- a/test_prepare_ego4d_av_eot_int_review.py +++ b/test_prepare_ego4d_av_eot_int_review.py @@ -129,10 +129,24 @@ class Ego4DAVCandidateTests(unittest.TestCase): 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)) + clip["camera_wearer"]["voice_segments"].append(segment("wearer", 11.8, 13.2)) with self.assertRaisesRegex(ValueError, "exceeds the audited limit"): derive_clip_candidates(clip, split="val", config=CandidateConfig()) + def test_zero_duration_voice_segment_is_ignored_with_explicit_audit(self) -> None: + clip = fixture_clip() + clip["persons"][1]["voice_segments"].append(segment("p2", 9.0, 9.0)) + audit: dict[str, object] = {} + rows, timeline = derive_clip_candidates( + clip, split="val", config=CandidateConfig(), audit_out=audit + ) + self.assertEqual(audit["zero_duration_voice_segment_count"], 1) + self.assertFalse(any(row["start_seconds"] == 9.0 for row in timeline)) + self.assertTrue(all( + row["temporal_evidence"]["zero_duration_voice_segment_count"] == 1 + for row in rows + )) + if __name__ == "__main__": unittest.main()