247 lines
11 KiB
Python
247 lines
11 KiB
Python
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())
|