Audit malformed Ego4D voice segments
This commit is contained in:
@@ -55,6 +55,18 @@ handoffs are added as independently reviewable hard-negative proposals.
|
|||||||
Timing rules only select and describe candidates; they never assign semantic
|
Timing rules only select and describe candidates; they never assign semantic
|
||||||
positive labels.
|
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
|
## 2. Inspect what will be uploaded
|
||||||
|
|
||||||
The LLM client refuses to upload transcript excerpts without an explicit
|
The LLM client refuses to upload transcript excerpts without an explicit
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class CandidateConfig:
|
|||||||
relation_window_seconds: float = 3.0
|
relation_window_seconds: float = 3.0
|
||||||
same_speaker_pause_max_seconds: float = 2.0
|
same_speaker_pause_max_seconds: float = 2.0
|
||||||
other_handover_max_seconds: float = 1.5
|
other_handover_max_seconds: float = 1.5
|
||||||
max_terminal_overrun_seconds: float = 0.1
|
max_terminal_overrun_seconds: float = 1.1
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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")
|
end = _number(row["end_time_sec"], field="end_time_sec")
|
||||||
else:
|
else:
|
||||||
raise ValueError("voice segment lacks clip-local start/end time")
|
raise ValueError("voice segment lacks clip-local start/end time")
|
||||||
if end <= start:
|
if end < start:
|
||||||
raise ValueError(f"voice segment must have positive duration: {start}..{end}")
|
raise ValueError(f"voice segment has negative duration: {start}..{end}")
|
||||||
return start, end
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
@@ -111,7 +111,9 @@ def _addressed_to_wearer(
|
|||||||
return any(overlaps)
|
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."""
|
"""Return validated clip-local speaker segments and the wearer person ID."""
|
||||||
clip_uid = str(clip.get("clip_uid", "")).strip()
|
clip_uid = str(clip.get("clip_uid", "")).strip()
|
||||||
if not clip_uid:
|
if not clip_uid:
|
||||||
@@ -142,6 +144,7 @@ def load_clip_segments(clip: dict[str, Any]) -> tuple[list[SpeechSegment], str]:
|
|||||||
|
|
||||||
segments: list[SpeechSegment] = []
|
segments: list[SpeechSegment] = []
|
||||||
seen: set[tuple[str, float, float]] = set()
|
seen: set[tuple[str, float, float]] = set()
|
||||||
|
zero_duration: list[dict[str, Any]] = []
|
||||||
ordinal = 0
|
ordinal = 0
|
||||||
for person in people:
|
for person in people:
|
||||||
person_id = _person_key(person.get("person_id"))
|
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:
|
if key in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(key)
|
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(
|
segments.append(
|
||||||
SpeechSegment(
|
SpeechSegment(
|
||||||
uid=f"{clip_uid}/speech/{ordinal:05d}",
|
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:]):
|
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:
|
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")
|
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
|
return segments, wearer_id
|
||||||
|
|
||||||
|
|
||||||
@@ -294,9 +312,11 @@ def _base_candidate(
|
|||||||
|
|
||||||
|
|
||||||
def derive_clip_candidates(
|
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]]]:
|
) -> 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(
|
declared_clip_end = _number(
|
||||||
clip.get("clip_end_sec", max((row.end for row in segments), default=0.0)),
|
clip.get("clip_end_sec", max((row.end for row in segments), default=0.0)),
|
||||||
field="clip_end_sec",
|
field="clip_end_sec",
|
||||||
@@ -427,7 +447,20 @@ def derive_clip_candidates(
|
|||||||
"terminal_voice_overrun_seconds": terminal_overrun,
|
"terminal_voice_overrun_seconds": terminal_overrun,
|
||||||
"max_allowed_terminal_overrun_seconds": config.max_terminal_overrun_seconds,
|
"max_allowed_terminal_overrun_seconds": config.max_terminal_overrun_seconds,
|
||||||
"terminal_voice_endpoint_preserved_not_clamped": True,
|
"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]
|
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("--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("--other-handover-max-seconds", type=float, default=1.5)
|
||||||
parser.add_argument(
|
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).",
|
help="Allow and audit a bounded official voice endpoint past clip_end_sec (default: %(default)s).",
|
||||||
)
|
)
|
||||||
parser.add_argument("--overwrite", action="store_true")
|
parser.add_argument("--overwrite", action="store_true")
|
||||||
@@ -516,10 +549,16 @@ def main() -> None:
|
|||||||
config = CandidateConfig(*numeric)
|
config = CandidateConfig(*numeric)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
timelines: dict[str, list[dict[str, Any]]] = {}
|
timelines: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
clip_audits: dict[str, dict[str, Any]] = {}
|
||||||
for clip, split in selected:
|
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)
|
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:
|
if not rows:
|
||||||
raise ValueError("selected annotations produced no candidates")
|
raise ValueError("selected annotations produced no candidates")
|
||||||
missing_voice_count_by_clip = {
|
missing_voice_count_by_clip = {
|
||||||
@@ -545,11 +584,20 @@ def main() -> None:
|
|||||||
_write_jsonl(output, rows)
|
_write_jsonl(output, rows)
|
||||||
_write_json(
|
_write_json(
|
||||||
timeline_output,
|
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)
|
counts = Counter(row["candidate_source"] for row in rows)
|
||||||
print(f"clips={len(selected)} candidates={len(rows)} output={output}")
|
print(f"clips={len(selected)} candidates={len(rows)} output={output}")
|
||||||
print("candidate_sources=" + json.dumps(dict(sorted(counts.items())), ensure_ascii=False))
|
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}")
|
print(f"timelines={timeline_output}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -129,10 +129,24 @@ class Ego4DAVCandidateTests(unittest.TestCase):
|
|||||||
def test_large_terminal_endpoint_overrun_is_rejected(self) -> None:
|
def test_large_terminal_endpoint_overrun_is_rejected(self) -> None:
|
||||||
clip = fixture_clip()
|
clip = fixture_clip()
|
||||||
clip["clip_end_sec"] = 11.95
|
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"):
|
with self.assertRaisesRegex(ValueError, "exceeds the audited limit"):
|
||||||
derive_clip_candidates(clip, split="val", config=CandidateConfig())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user