Files
icassp2027-experiment/derive_easycom_interaction_events.py
T

875 lines
33 KiB
Python

#!/usr/bin/env python3
"""Derive auditable EasyCom interaction-event proxies from transcripts.
The released EasyCom transcription JSON files provide participant-resolved
``Start_Frame``/``End_Frame`` intervals, text, and ``Target_of_Speech``. They
do *not* provide a semantic backchannel label. This script therefore keeps
deterministic timing events separate from high-precision weak proxies:
* ordinary speaker switch;
* backchannel proxy (short feedback while another participant retains floor);
* interruption proxy (overlapping onset followed by floor takeover);
* overlap without takeover;
* non-verbal feedback; and
* ambiguous candidates, which should be masked from auxiliary training.
The source dataset is never modified. With ``--output-dir`` the script writes
JSONL/CSV reports and optional 5 Hz ``.npz`` onset arrays to a separate path.
Without it, the command is a read-only audit.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import ast
import struct
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Sequence
from easycom_manifest import ALL_SESSIONS, TEST_SESSIONS, parse_timecode, probe_video
SOURCE_FPS = 20
TASK_FPS = 5
CAMERA_WEARER_ID = 2
FRAMES_PER_TASK_STEP = SOURCE_FPS // TASK_FPS
AUXILIARY_EVENT_NAMES = (
"ordinary_switch",
"backchannel_proxy_high",
"overlap_takeover",
"interruption_proxy_high",
"overlap_hold",
"nonverbal_feedback",
)
AMBIGUOUS_EVENT_NAMES = (
"backchannel_candidate",
"interruption_candidate",
"nonverbal_candidate",
)
MARKER_RE = re.compile(r"\[[A-Za-z]\]")
WORD_RE = re.compile(r"[a-z]+(?:'[a-z]+)?")
TRAILING_CUTOFF_RE = re.compile(r"-\s*(?:\[[A-Za-z]\]\s*)*$")
PURE_NONVERBAL_RE = re.compile(r"^(?:\s*\[(?:L|C|T|H|U)\]\s*[.!?]?\s*)+$")
# Deliberately conservative. A lexical match is not sufficient: timing must
# also show that another participant retained the floor.
FEEDBACK_PHRASES = {
"alright",
"awesome",
"cool",
"definitely",
"exactly",
"fair",
"good",
"got it",
"great",
"huh",
"i know",
"i see",
"interesting",
"makes sense",
"mhm",
"mm",
"mm hmm",
"nice",
"no",
"no way",
"nope",
"oh",
"oh man",
"okay",
"really",
"right",
"sure",
"true",
"uh huh",
"uh-huh",
"wow",
"yeah",
"yep",
"yes",
"yup",
}
FEEDBACK_TOKENS = {
token for phrase in FEEDBACK_PHRASES for token in WORD_RE.findall(phrase)
}
@dataclass(frozen=True)
class Clip:
path: str
transcript_path: str
stem: str
source_start_seconds: float
source_end_seconds: float
dense_start_frame: int
num_frames: int
segment_id: int
discontinuity_before_seconds: float | None
@property
def dense_start_seconds(self) -> float:
return self.dense_start_frame / SOURCE_FPS
@dataclass(frozen=True)
class Utterance:
uid: str
session: str
clip_stem: str
clip_index: int
segment_id: int
participant_id: int
target_of_speech: tuple[int | str, ...]
transcription: str
source_start_frame: int
source_end_frame: int
dense_start_frame: int
dense_end_frame_exclusive: int
dense_start_seconds: float
dense_end_seconds: float
duration_seconds: float
starts_at_clip_boundary: bool
ends_at_clip_boundary: bool
annotation_end_frame_clamped_by: int
is_cross_clip_continuation: bool
@dataclass(frozen=True)
class Event:
uid: str
session: str
split: str
event_type: str
confidence: str
trainable_auxiliary: bool
participant_id: int
participant_role: str
previous_floor_participant_id: int | None
retained_floor_participant_id: int | None
target_matches_previous_floor: bool
clip_stem: str
source_start_frame: int
dense_start_seconds: float
task_step_index: int
duration_seconds: float
onset_overlap_seconds: float
gap_from_previous_seconds: float | None
speaker_after_previous_end_seconds: float
previous_transcript_has_cutoff: bool
previous_transcription: str | None
lexical_feedback: bool
pure_nonverbal: bool
transcription: str
target_of_speech: tuple[int | str, ...]
reason: str
@dataclass(frozen=True)
class Rules:
context_seconds: float = 1.5
backchannel_max_seconds: float = 1.0
max_switch_gap_seconds: float = 0.6
min_overlap_seconds: float = 0.1
takeover_candidate_seconds: float = 0.4
takeover_high_seconds: float = 0.8
max_contiguous_source_gap_seconds: float = 1.0
def _load_json(path: Path) -> Any:
payload = path.read_bytes()
errors: list[str] = []
for encoding in ("utf-8", "cp1252", "latin1"):
try:
return json.loads(payload.decode(encoding))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
errors.append(f"{encoding}: {exc}")
raise ValueError(f"Could not decode {path}: {'; '.join(errors)}")
def _normalized_words(text: str) -> tuple[str, ...]:
return tuple(WORD_RE.findall(MARKER_RE.sub(" ", text).lower()))
def is_lexical_feedback(text: str) -> bool:
if "?" in text:
return False
words = _normalized_words(text)
if not words or len(words) > 5:
return False
phrase = " ".join(words)
return phrase in FEEDBACK_PHRASES or all(word in FEEDBACK_TOKENS for word in words)
def is_pure_nonverbal(text: str) -> bool:
return PURE_NONVERBAL_RE.fullmatch(text.strip()) is not None
def has_trailing_cutoff(utterance: Utterance) -> bool:
# [X] at a one-minute boundary denotes a continued/truncated transcript,
# not evidence that another participant interrupted the speaker.
return (
TRAILING_CUTOFF_RE.search(utterance.transcription.strip()) is not None
and "[X]" not in utterance.transcription.upper()
and not utterance.ends_at_clip_boundary
)
def _target_values(value: Any) -> tuple[int | str, ...]:
values = value if isinstance(value, list) else [value]
normalized: list[int | str] = []
for item in values:
if isinstance(item, bool):
normalized.append(str(item))
elif isinstance(item, int):
normalized.append(item)
elif isinstance(item, float) and item.is_integer():
normalized.append(int(item))
else:
normalized.append(str(item))
return tuple(normalized)
def _target_length(path: Path | None) -> int | None:
if path is None:
return None
# Read only the NPY header so an audit does not require NumPy on the raw
# data host. NumPy is required only when --write-frame-arrays is requested.
with path.open("rb") as handle:
if handle.read(6) != bytes([147]) + b"NUMPY":
raise ValueError(f"Not a NumPy array: {path}")
major, _minor = handle.read(2)
length_size = 2 if major == 1 else 4
header_length = struct.unpack(
"<H" if length_size == 2 else "<I",
handle.read(length_size),
)[0]
header = ast.literal_eval(handle.read(header_length).decode("latin1").strip())
shape = header.get("shape")
if not isinstance(shape, tuple) or len(shape) != 2 or shape[1] != 3:
raise ValueError(f"Expected target [T,3], got {shape}: {path}")
return int(shape[0])
def build_clips(dataset_root: Path, session: str, rules: Rules) -> list[Clip]:
video_dir = dataset_root / "Main" / "Video_Compressed" / session
transcript_dir = dataset_root / "Main" / "Speech_Transcriptions" / session
if not video_dir.is_dir():
raise FileNotFoundError(f"Missing video directory: {video_dir}")
if not transcript_dir.is_dir():
raise FileNotFoundError(f"Missing transcript directory: {transcript_dir}")
video_paths = sorted(video_dir.glob("*.mp4"), key=lambda p: parse_timecode(p.stem))
transcript_paths = {path.stem: path for path in transcript_dir.glob("*.json")}
video_stems = {path.stem for path in video_paths}
if video_stems != set(transcript_paths):
missing_json = sorted(video_stems - set(transcript_paths))
missing_video = sorted(set(transcript_paths) - video_stems)
raise ValueError(
f"{session} video/transcript inventory differs; "
f"missing_json={missing_json}, missing_video={missing_video}"
)
clips: list[Clip] = []
dense_start_frame = 0
previous_source_end: float | None = None
segment_id = 0
for path in video_paths:
media = probe_video(path)
frames = int(media["frames_at_20hz"])
source_start = parse_timecode(path.stem)
source_end = source_start + frames / SOURCE_FPS
delta = None if previous_source_end is None else source_start - previous_source_end
if delta is not None and abs(delta) > rules.max_contiguous_source_gap_seconds:
segment_id += 1
clips.append(
Clip(
path=str(path.resolve()),
transcript_path=str(transcript_paths[path.stem].resolve()),
stem=path.stem,
source_start_seconds=source_start,
source_end_seconds=source_end,
dense_start_frame=dense_start_frame,
num_frames=frames,
segment_id=segment_id,
discontinuity_before_seconds=delta,
)
)
dense_start_frame += frames
previous_source_end = source_end
return clips
def load_utterances(session: str, clips: Sequence[Clip]) -> list[Utterance]:
utterances: list[Utterance] = []
per_clip: list[list[Utterance]] = []
for clip_index, clip in enumerate(clips):
payload = _load_json(Path(clip.transcript_path))
if not isinstance(payload, list):
raise ValueError(f"Expected a list in {clip.transcript_path}")
clip_rows: list[Utterance] = []
for row_index, row in enumerate(payload):
required = {
"Start_Frame",
"End_Frame",
"Participant_ID",
"Transcription",
"Target_of_Speech",
}
if not isinstance(row, dict) or not required.issubset(row):
raise ValueError(
f"Invalid transcription row {row_index} in {clip.transcript_path}: {row}"
)
start = int(row["Start_Frame"])
annotation_end = int(row["End_Frame"])
end_overrun = max(0, annotation_end - clip.num_frames)
# A few released MP4/container frame counts differ from the JSON
# endpoint by one frame. Permit only a tightly bounded clamp and
# expose it in the audit report; larger disagreements remain fatal.
if end_overrun > 2:
raise ValueError(
f"Annotation endpoint exceeds media by {end_overrun} frames in "
f"{clip.transcript_path}: End_Frame={annotation_end}, "
f"clip_frames={clip.num_frames}"
)
end = min(annotation_end, clip.num_frames)
if start < 1 or end < start:
raise ValueError(
f"Invalid inclusive frame interval [{start},{annotation_end}] in "
f"{clip.transcript_path}; clip has {clip.num_frames} frames"
)
dense_start = clip.dense_start_frame + start - 1
dense_end = clip.dense_start_frame + end
uid = f"{session}/{clip.stem}/{row_index:04d}"
clip_rows.append(
Utterance(
uid=uid,
session=session,
clip_stem=clip.stem,
clip_index=clip_index,
segment_id=clip.segment_id,
participant_id=int(row["Participant_ID"]),
target_of_speech=_target_values(row["Target_of_Speech"]),
transcription=str(row["Transcription"]),
source_start_frame=start,
source_end_frame=end,
dense_start_frame=dense_start,
dense_end_frame_exclusive=dense_end,
dense_start_seconds=dense_start / SOURCE_FPS,
dense_end_seconds=dense_end / SOURCE_FPS,
duration_seconds=(end - start + 1) / SOURCE_FPS,
starts_at_clip_boundary=start <= 2,
ends_at_clip_boundary=end >= clip.num_frames - 1,
annotation_end_frame_clamped_by=end_overrun,
is_cross_clip_continuation=False,
)
)
per_clip.append(clip_rows)
# Mark, rather than merge, cross-file continuations. Their timing remains
# useful for floor context, but they must not create a new event onset.
for clip_index, rows in enumerate(per_clip):
previous_rows = per_clip[clip_index - 1] if clip_index > 0 else []
contiguous = (
clip_index > 0 and clips[clip_index].segment_id == clips[clip_index - 1].segment_id
)
for row in rows:
continuation = False
if contiguous and row.starts_at_clip_boundary:
candidates = [
prev
for prev in previous_rows
if prev.participant_id == row.participant_id
and prev.ends_at_clip_boundary
]
continuation = bool(candidates) and (
"[X]" in row.transcription.upper()
or any("[X]" in prev.transcription.upper() for prev in candidates)
)
utterances.append(
Utterance(**{**asdict(row), "is_cross_clip_continuation": continuation})
)
return sorted(utterances, key=lambda u: (u.dense_start_frame, u.dense_end_frame_exclusive, u.uid))
def _overlap_seconds(row: Utterance, start: float, end: float) -> float:
return max(0.0, min(row.dense_end_seconds, end) - max(row.dense_start_seconds, start))
def _coverage_by_participant(
utterances: Sequence[Utterance],
*,
segment_id: int,
start: float,
end: float,
exclude_participant: int | None = None,
) -> dict[int, float]:
intervals: dict[int, list[tuple[float, float]]] = defaultdict(list)
for row in utterances:
if row.segment_id != segment_id or row.participant_id == exclude_participant:
continue
left = max(start, row.dense_start_seconds)
right = min(end, row.dense_end_seconds)
if right > left:
intervals[row.participant_id].append((left, right))
coverage: dict[int, float] = {}
for participant, spans in intervals.items():
spans.sort()
total = 0.0
left, right = spans[0]
for next_left, next_right in spans[1:]:
if next_left <= right:
right = max(right, next_right)
else:
total += right - left
left, right = next_left, next_right
coverage[participant] = total + right - left
return coverage
def _dominant(coverage: dict[int, float]) -> int | None:
if not coverage:
return None
participant, seconds = max(coverage.items(), key=lambda item: (item[1], -item[0]))
return participant if seconds > 1e-9 else None
def _previous_floor(
row: Utterance,
utterances: Sequence[Utterance],
rules: Rules,
) -> tuple[int | None, Utterance | None, float | None, float]:
candidates = [
other
for other in utterances
if other.segment_id == row.segment_id
and other.participant_id != row.participant_id
and other.dense_start_seconds < row.dense_start_seconds
and other.dense_end_seconds >= row.dense_start_seconds - rules.max_switch_gap_seconds
]
if not candidates:
return None, None, None, 0.0
active = [other for other in candidates if other.dense_end_seconds > row.dense_start_seconds]
previous = max(active or candidates, key=lambda other: other.dense_end_seconds)
gap = row.dense_start_seconds - previous.dense_end_seconds
overlap = max(0.0, -gap)
return previous.participant_id, previous, gap, overlap
def classify_utterance(
row: Utterance,
utterances: Sequence[Utterance],
*,
camera_wearer_id: int,
rules: Rules,
) -> Event:
split = "test" if row.session in TEST_SESSIONS else "train"
role = "wearer" if row.participant_id == camera_wearer_id else "other"
lexical = is_lexical_feedback(row.transcription)
nonverbal = is_pure_nonverbal(row.transcription)
previous_pid, previous, gap, onset_overlap = _previous_floor(row, utterances, rules)
pre_coverage = _coverage_by_participant(
utterances,
segment_id=row.segment_id,
start=row.dense_start_seconds - rules.context_seconds,
end=row.dense_start_seconds,
exclude_participant=row.participant_id,
)
post_coverage = _coverage_by_participant(
utterances,
segment_id=row.segment_id,
start=row.dense_end_seconds,
end=row.dense_end_seconds + rules.context_seconds,
exclude_participant=row.participant_id,
)
pre_pid = _dominant(pre_coverage) or previous_pid
post_pid = _dominant(post_coverage)
retained_pid = pre_pid if pre_pid is not None and pre_pid == post_pid else None
old_end = previous.dense_end_seconds if previous is not None else row.dense_start_seconds
speaker_after_old = max(0.0, row.dense_end_seconds - max(row.dense_start_seconds, old_end))
previous_cutoff = has_trailing_cutoff(previous) if previous is not None else False
target_matches = previous_pid is not None and previous_pid in row.target_of_speech
short = row.duration_seconds <= rules.backchannel_max_seconds + 1e-9
overlaps = onset_overlap >= rules.min_overlap_seconds - 1e-9
takeover_candidate = speaker_after_old >= rules.takeover_candidate_seconds - 1e-9
takeover_high = speaker_after_old >= rules.takeover_high_seconds - 1e-9
event_type = "unclassified"
confidence = "none"
trainable = False
reason = "no other-speaker floor relation meeting a proxy rule"
laughter_feedback = nonverbal and "[L]" in row.transcription.upper()
if row.is_cross_clip_continuation:
event_type = "cross_clip_continuation"
reason = "[X] continuation at a contiguous one-minute file boundary"
elif laughter_feedback and (retained_pid is not None or overlaps):
event_type = "nonverbal_feedback"
confidence = "high"
trainable = True
reason = "laughter-only marker during/around another participant's retained floor"
elif nonverbal and (retained_pid is not None or overlaps):
event_type = "nonverbal_candidate"
confidence = "ambiguous"
reason = "pure hesitation/cough/throat-clear/unintelligible marker; feedback function is unknown"
elif short and retained_pid is not None:
if lexical:
event_type = "backchannel_proxy_high"
confidence = "high"
trainable = True
reason = "short lexical feedback; the same other participant dominates before and after"
else:
event_type = "backchannel_candidate"
confidence = "ambiguous"
reason = "short contribution while another participant retains floor, but no conservative lexical cue"
elif previous is not None and (overlaps or (previous_cutoff and gap is not None and gap <= 0.15)):
if previous_cutoff and takeover_candidate:
event_type = "interruption_proxy_high"
confidence = "high"
trainable = True
reason = "previous transcript is cut off and the incoming speaker continues after that floor ends"
elif takeover_high:
event_type = "overlap_takeover"
confidence = "timing"
trainable = True
reason = "overlapping onset followed by sustained takeover, without semantic interruption evidence"
elif takeover_candidate:
event_type = "interruption_candidate"
confidence = "ambiguous"
reason = "overlapping onset followed by a possible short takeover; interruption intent is unknown"
else:
event_type = "overlap_hold"
confidence = "timing"
trainable = True
reason = "overlap without enough evidence that the incoming speaker took the floor"
elif (
previous is not None
and gap is not None
and 0.0 <= gap <= rules.max_switch_gap_seconds
and row.duration_seconds >= rules.takeover_high_seconds
):
event_type = "ordinary_switch"
confidence = "timing"
trainable = True
reason = "other speaker ended recently and the incoming speaker sustained a new turn"
return Event(
uid=row.uid,
session=row.session,
split=split,
event_type=event_type,
confidence=confidence,
trainable_auxiliary=trainable,
participant_id=row.participant_id,
participant_role=role,
previous_floor_participant_id=previous_pid,
retained_floor_participant_id=retained_pid,
target_matches_previous_floor=target_matches,
clip_stem=row.clip_stem,
source_start_frame=row.source_start_frame,
dense_start_seconds=row.dense_start_seconds,
task_step_index=row.dense_start_frame // FRAMES_PER_TASK_STEP,
duration_seconds=row.duration_seconds,
onset_overlap_seconds=onset_overlap,
gap_from_previous_seconds=gap,
speaker_after_previous_end_seconds=speaker_after_old,
previous_transcript_has_cutoff=previous_cutoff,
previous_transcription=None if previous is None else previous.transcription,
lexical_feedback=lexical,
pure_nonverbal=nonverbal,
transcription=row.transcription,
target_of_speech=row.target_of_speech,
reason=reason,
)
def _serialize_event(event: Event) -> dict[str, Any]:
payload = asdict(event)
payload["target_of_speech"] = list(event.target_of_speech)
return payload
def _write_session(
output_dir: Path,
session: str,
events: Sequence[Event],
*,
target_steps: int | None,
write_frame_arrays: bool,
overwrite: bool,
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
rows = [_serialize_event(event) for event in events if event.event_type != "unclassified"]
jsonl_path = output_dir / f"{session}.events.jsonl"
csv_path = output_dir / f"{session}.events.csv"
for path in (jsonl_path, csv_path):
if path.exists() and not overwrite:
raise FileExistsError(f"Refusing to overwrite {path}; pass --overwrite")
with jsonl_path.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
fieldnames = list(rows[0]) if rows else [field.name for field in Event.__dataclass_fields__.values()]
with csv_path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fieldnames)
writer.writeheader()
for row in rows:
row = dict(row)
row["target_of_speech"] = json.dumps(row["target_of_speech"], ensure_ascii=False)
writer.writerow(row)
if write_frame_arrays:
if target_steps is None:
raise ValueError("--write-frame-arrays requires --target-root")
npz_path = output_dir / f"{session}.aux_targets.npz"
if npz_path.exists() and not overwrite:
raise FileExistsError(f"Refusing to overwrite {npz_path}; pass --overwrite")
try:
import numpy as np
except ImportError as exc:
raise RuntimeError("numpy is required to write aligned auxiliary arrays") from exc
onset = np.zeros((target_steps, len(AUXILIARY_EVENT_NAMES)), dtype=np.uint8)
role = np.zeros_like(onset, dtype=np.int8)
collisions = np.zeros_like(onset, dtype=np.uint8)
event_to_column = {name: index for index, name in enumerate(AUXILIARY_EVENT_NAMES)}
for event in events:
column = event_to_column.get(event.event_type)
step = event.task_step_index
if column is None or not event.trainable_auxiliary or not 0 <= step < target_steps:
continue
encoded_role = 1 if event.participant_role == "wearer" else 2
if onset[step, column]:
collisions[step, column] = 1
if role[step, column] != encoded_role:
role[step, column] = -1
else:
onset[step, column] = 1
role[step, column] = encoded_role
np.savez_compressed(
npz_path,
event_onsets=onset,
event_roles=role,
collisions=collisions,
event_names=np.asarray(AUXILIARY_EVENT_NAMES),
task_fps=np.asarray(TASK_FPS),
target_steps=np.asarray(target_steps),
)
def _session_summary(
session: str,
clips: Sequence[Clip],
utterances: Sequence[Utterance],
events: Sequence[Event],
target_steps: int | None,
) -> dict[str, Any]:
counts = Counter(event.event_type for event in events)
trainable = Counter(
event.event_type for event in events if event.trainable_auxiliary
)
dense_frames = sum(clip.num_frames for clip in clips)
expected_steps = dense_frames // FRAMES_PER_TASK_STEP
return {
"session": session,
"split": "test" if session in TEST_SESSIONS else "train",
"clips": len(clips),
"source_discontinuities": max((clip.segment_id for clip in clips), default=0),
"utterances": len(utterances),
"annotation_endpoints_clamped": sum(
row.annotation_end_frame_clamped_by > 0 for row in utterances
),
"annotation_frames_clamped_total": sum(
row.annotation_end_frame_clamped_by for row in utterances
),
"dense_frames": dense_frames,
"expected_5hz_steps": expected_steps,
"target_steps": target_steps,
"target_minus_expected_steps": (
None if target_steps is None else target_steps - expected_steps
),
"event_counts": dict(sorted(counts.items())),
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dataset-root", type=Path, required=True)
parser.add_argument(
"--target-root",
type=Path,
help="Optional EgoSpeak target_perframe directory for exact 5 Hz length/aligned .npz output",
)
parser.add_argument("--output-dir", type=Path, help="Omit for a read-only audit")
parser.add_argument(
"--write-frame-arrays",
action="store_true",
help="Write aligned 5 Hz .npz arrays; requires --target-root and numpy",
)
parser.add_argument("--sessions", nargs="+", default=list(ALL_SESSIONS))
parser.add_argument("--camera-wearer-id", type=int, default=CAMERA_WEARER_ID)
parser.add_argument("--context-seconds", type=float, default=1.5)
parser.add_argument("--backchannel-max-seconds", type=float, default=1.0)
parser.add_argument("--max-switch-gap-seconds", type=float, default=0.6)
parser.add_argument("--min-overlap-seconds", type=float, default=0.1)
parser.add_argument("--takeover-candidate-seconds", type=float, default=0.4)
parser.add_argument("--takeover-high-seconds", type=float, default=0.8)
parser.add_argument("--max-contiguous-source-gap-seconds", type=float, default=1.0)
parser.add_argument("--examples-per-event", type=int, default=3)
parser.add_argument("--overwrite", action="store_true")
return parser.parse_args()
def main() -> None:
args = parse_args()
if SOURCE_FPS % TASK_FPS:
raise RuntimeError("source/task FPS ratio must be integral")
rules = Rules(
context_seconds=args.context_seconds,
backchannel_max_seconds=args.backchannel_max_seconds,
max_switch_gap_seconds=args.max_switch_gap_seconds,
min_overlap_seconds=args.min_overlap_seconds,
takeover_candidate_seconds=args.takeover_candidate_seconds,
takeover_high_seconds=args.takeover_high_seconds,
max_contiguous_source_gap_seconds=args.max_contiguous_source_gap_seconds,
)
for name, value in asdict(rules).items():
if value < 0:
raise ValueError(f"{name} must be non-negative")
unknown = sorted(set(args.sessions) - set(ALL_SESSIONS))
if unknown:
raise ValueError(f"Unknown EasyCom sessions: {unknown}")
if args.write_frame_arrays and args.output_dir is None:
raise ValueError("--write-frame-arrays requires --output-dir")
if args.write_frame_arrays and args.target_root is None:
raise ValueError("--write-frame-arrays requires --target-root")
all_events: list[Event] = []
summaries: list[dict[str, Any]] = []
for session in args.sessions:
clips = build_clips(args.dataset_root.resolve(), session, rules)
utterances = load_utterances(session, clips)
events = [
classify_utterance(
row,
utterances,
camera_wearer_id=args.camera_wearer_id,
rules=rules,
)
for row in utterances
]
target_path = (
None
if args.target_root is None
else args.target_root.resolve() / f"{session}.npy"
)
if target_path is not None and not target_path.is_file():
raise FileNotFoundError(f"Missing target: {target_path}")
target_steps = _target_length(target_path)
summary = _session_summary(session, clips, utterances, events, target_steps)
if summary["target_minus_expected_steps"] not in (None, 0):
raise ValueError(
f"{session} target/media mismatch: {summary['target_minus_expected_steps']:+d} steps"
)
summaries.append(summary)
all_events.extend(events)
if args.output_dir is not None:
_write_session(
args.output_dir.resolve(),
session,
events,
target_steps=target_steps,
write_frame_arrays=args.write_frame_arrays,
overwrite=args.overwrite,
)
counts = Counter(event.event_type for event in events)
print(
f"{session}: utterances={len(utterances)} "
+ " ".join(f"{name}={count}" for name, count in sorted(counts.items()))
)
aggregate = Counter(event.event_type for event in all_events)
trainable = Counter(
event.event_type for event in all_events if event.trainable_auxiliary
)
report = {
"schema_version": 1,
"dataset": "EasyCom",
"dataset_root": str(args.dataset_root.resolve()),
"target_root": None if args.target_root is None else str(args.target_root.resolve()),
"source_fps": SOURCE_FPS,
"task_fps": TASK_FPS,
"camera_wearer_participant_id": args.camera_wearer_id,
"rules": asdict(rules),
"auxiliary_event_names": list(AUXILIARY_EVENT_NAMES),
"ambiguous_event_names": list(AMBIGUOUS_EVENT_NAMES),
"semantic_warning": (
"Backchannel and interruption are transcript/timing-derived weak proxies, "
"not released EasyCom ground-truth semantic annotations."
),
"recommended_training_policy": (
"Use only trainable_auxiliary events for auxiliary loss; mask candidate and "
"unclassified rows. Keep EgoSpeak's original three-label evaluator unchanged."
),
"event_counts": dict(sorted(aggregate.items())),
"trainable_auxiliary_counts": dict(sorted(trainable.items())),
"sessions": summaries,
"examples": {},
}
for event_type in sorted(aggregate):
report["examples"][event_type] = [
_serialize_event(event)
for event in all_events
if event.event_type == event_type
][: args.examples_per_event]
print("\nAggregate event counts")
for name, count in sorted(aggregate.items()):
print(f" {name:<28} {count:>6}")
print("Trainable auxiliary counts")
for name, count in sorted(trainable.items()):
print(f" {name:<28} {count:>6}")
if args.examples_per_event:
print("Examples")
for event_type in sorted(aggregate):
examples = [
event for event in all_events if event.event_type == event_type
][: args.examples_per_event]
for event in examples:
text = " ".join(event.transcription.split())
if len(text) > 100:
text = text[:97] + "..."
print(
f" {event_type:<28} {event.session} "
f"t={event.dense_start_seconds:.2f}s p={event.participant_id}: {text}"
)
print(
"\nWARNING: backchannel/interruption outputs are weak proxies; "
"ambiguous candidates are intentionally masked."
)
if args.output_dir is not None:
output_dir = args.output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
report_path = output_dir / "summary.json"
if report_path.exists() and not args.overwrite:
raise FileExistsError(f"Refusing to overwrite {report_path}; pass --overwrite")
report_path.write_text(
json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
print(f"Saved reports to {output_dir}")
if __name__ == "__main__":
main()