from __future__ import annotations import argparse import csv import json import subprocess from collections import Counter from fractions import Fraction from pathlib import Path from typing import Any from openpyxl import load_workbook def _identifier(value: Any) -> str: if value is None: return "" if isinstance(value, float) and value.is_integer(): return str(int(value)) return str(value).strip() def _read_labels(path: Path) -> list[dict[str, Any]]: workbook = load_workbook(path, read_only=True, data_only=True) sheet = workbook.active rows = sheet.iter_rows(values_only=True) header = next(rows, None) if header is None: raise ValueError(f"empty workbook: {path}") names = [str(value).strip().lower() if value is not None else "" for value in header] required = ("video_id", "clip_id", "text", "label", "annotation") missing = set(required) - set(names) if missing: raise ValueError(f"missing required label columns: {sorted(missing)}") indexes = {name: names.index(name) for name in required} records = [] for values in rows: if not values or all(value is None for value in values): continue record = {name: values[index] if index < len(values) else None for name, index in indexes.items()} record["video_id"] = _identifier(record["video_id"]) record["clip_id"] = _identifier(record["clip_id"]) record["text"] = "" if record["text"] is None else str(record["text"]).strip() record["annotation"] = "" if record["annotation"] is None else str(record["annotation"]).strip() if record["label"] is not None: try: record["label"] = float(record["label"]) except (TypeError, ValueError): pass records.append(record) workbook.close() return records def _probe_video(path: Path) -> dict[str, Any]: result = subprocess.run( [ "ffprobe", "-v", "error", "-show_entries", "format=duration:stream=codec_type,codec_name,width,height,avg_frame_rate,r_frame_rate,nb_frames,sample_rate,channels:frame=media_type,best_effort_timestamp_time,pkt_duration_time", "-show_frames", "-of", "json", str(path), ], check=True, capture_output=True, text=True, ) payload = json.loads(result.stdout) streams = payload.get("streams", []) video = next((stream for stream in streams if stream.get("codec_type") == "video"), {}) audio = next((stream for stream in streams if stream.get("codec_type") == "audio"), {}) fps_text = video.get("avg_frame_rate") or video.get("r_frame_rate") or "0/1" try: fps = float(Fraction(fps_text)) except (ValueError, ZeroDivisionError): fps = 0.0 container_duration = float(payload.get("format", {}).get("duration", 0.0)) frame_times: dict[str, list[tuple[float, float]]] = {"video": [], "audio": []} for frame in payload.get("frames", []): media_type = frame.get("media_type") timestamp = frame.get("best_effort_timestamp_time") if media_type not in frame_times or timestamp is None: continue try: start = float(timestamp) packet_duration = float(frame.get("pkt_duration_time", 0.0) or 0.0) except (TypeError, ValueError): continue frame_times[media_type].append((start, packet_duration)) video_times = frame_times["video"] audio_times = frame_times["audio"] fallback_frame_duration = 1.0 / fps if fps > 0 else 0.0 video_start = min((item[0] for item in video_times), default=0.0) video_end = max((start + (packet_duration or fallback_frame_duration) for start, packet_duration in video_times), default=container_duration) audio_start = min((item[0] for item in audio_times), default=0.0) audio_end = max((start + packet_duration for start, packet_duration in audio_times), default=container_duration) decoded_duration = max(0.0, video_end - video_start) audio_duration = max(0.0, audio_end - audio_start) return { "duration_s": decoded_duration or container_duration, "container_duration_s": container_duration, "audio_duration_s": audio_duration, "video_timeline_start_s": video_start, "video_timeline_end_s": video_end, "audio_timeline_start_s": audio_start, "audio_timeline_end_s": audio_end, "video_codec": video.get("codec_name", ""), "width": video.get("width", ""), "height": video.get("height", ""), "fps": fps, "video_frames": len(video_times), "container_video_frames": video.get("nb_frames", ""), "audio_codec": audio.get("codec_name", ""), "audio_sample_rate": audio.get("sample_rate", ""), "audio_channels": audio.get("channels", ""), "has_audio": bool(audio), } def audit_dataset(video_root: Path, label_file: Path, output_dir: Path, expected_count: int = 100) -> dict[str, Any]: records = _read_labels(label_file) videos: dict[tuple[str, str], Path] = {} duplicate_video_files: list[str] = [] for video in sorted(video_root.rglob("*.mp4")): key = (video.parent.name, video.stem) if key in videos: duplicate_video_files.append(str(video)) else: videos[key] = video keys = [(str(row["video_id"]), str(row["clip_id"])) for row in records] counts = Counter(keys) duplicate_rows = [list(key) for key, count in counts.items() if count > 1] matched_keys = set(keys) & set(videos) missing_keys = [key for key in keys if key not in videos] extra_keys = sorted(set(videos) - set(keys)) class_mismatches = [] probe_errors: list[dict[str, str]] = [] probe_by_key: dict[tuple[str, str], dict[str, Any]] = {} for key in matched_keys: try: probe_by_key[key] = _probe_video(videos[key]) except (OSError, subprocess.CalledProcessError, json.JSONDecodeError, ValueError) as error: probe_errors.append({"video_id": key[0], "clip_id": key[1], "error": str(error)}) output_dir.mkdir(parents=True, exist_ok=True) manifest_path = output_dir / "manifest.csv" with manifest_path.open("w", encoding="utf-8-sig", newline="") as file: writer = csv.DictWriter( file, fieldnames=( "video_id", "clip_id", "group_id", "text", "label", "annotation", "video_path", "video_exists", "duration_s", "container_duration_s", "audio_duration_s", "video_timeline_start_s", "video_timeline_end_s", "audio_timeline_start_s", "audio_timeline_end_s", "video_codec", "width", "height", "fps", "video_frames", "container_video_frames", "audio_codec", "audio_sample_rate", "audio_channels", "has_audio", ), ) writer.writeheader() for row, key in zip(records, keys): label = row["label"] annotation = str(row["annotation"]).strip().lower() expected_class = "negative" if isinstance(label, (int, float)) and label < 0 else ( "positive" if isinstance(label, (int, float)) and label > 0 else "neutral" ) if annotation in {"negative", "neutral", "positive"} and annotation != expected_class: class_mismatches.append({"video_id": key[0], "clip_id": key[1], "label": label, "annotation": annotation}) path = videos.get(key) probe = probe_by_key.get(key, {}) writer.writerow({ "video_id": key[0], "clip_id": key[1], "group_id": key[0], "text": row["text"], "label": row["label"], "annotation": row["annotation"], "video_path": str(path.relative_to(video_root)) if path else "", "video_exists": bool(path), **probe, }) durations = [info["duration_s"] for info in probe_by_key.values() if info["duration_s"] > 0] duration_out_of_range = [ {"video_id": key[0], "clip_id": key[1], "duration_s": info["duration_s"]} for key, info in probe_by_key.items() if not (2.648 <= info["duration_s"] <= 34.567) ] audio_missing = [list(key) for key, info in probe_by_key.items() if not info["has_audio"]] summary = { "video_root": str(video_root), "label_file": str(label_file), "expected_count": expected_count, "label_rows": len(records), "unique_video_clip_pairs": len(set(keys)), "unique_video_ids": len({key[0] for key in keys}), "video_files": len(videos), "matched_samples": len(matched_keys), "coverage_rate": len(matched_keys) / max(len(records), 1), "duration_min_s": min(durations) if durations else None, "duration_max_s": max(durations) if durations else None, "stated_duration_range_s": [2.648, 34.567], "duration_out_of_range": duration_out_of_range, "audio_stream_missing": audio_missing, "media_probe_errors": probe_errors, "missing_video_pairs": [list(key) for key in missing_keys], "unlisted_video_pairs": [list(key) for key in extra_keys], "duplicate_label_pairs": duplicate_rows, "duplicate_video_files": duplicate_video_files, "label_class_mismatches": class_mismatches, "manifest": str(manifest_path), } summary["coverage_status"] = ( "complete_with_metadata_warnings" if duration_out_of_range else "complete" ) summary_path = output_dir / "coverage_summary.json" summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") return summary def main() -> int: project_dir = Path(__file__).resolve().parents[1] repo_dir = project_dir.parent default_data = repo_dir / "E题数据" / "附件1-数据集原始多模态样本" / "MOSEI数据集部分原始视频-100条" parser = argparse.ArgumentParser(description="Audit the 100 raw-video Q1 samples and export a manifest.") parser.add_argument("--video-root", type=Path, default=default_data) parser.add_argument("--labels", type=Path, default=default_data / "label-100.xlsx") parser.add_argument("--output-dir", type=Path, default=project_dir / "outputs" / "audit") parser.add_argument("--expected-count", type=int, default=100) args = parser.parse_args() summary = audit_dataset(args.video_root, args.labels, args.output_dir, args.expected_count) print(json.dumps(summary, ensure_ascii=False, indent=2)) complete = ( summary["label_rows"] == args.expected_count and summary["unique_video_clip_pairs"] == args.expected_count and summary["matched_samples"] == args.expected_count and not summary["duplicate_label_pairs"] and not summary["label_class_mismatches"] and not summary["audio_stream_missing"] and not summary["media_probe_errors"] ) return 0 if complete else 1 if __name__ == "__main__": raise SystemExit(main())