nds-label-platform / step1_extract_frames.py
AutoZYX's picture
initial: port from Streamlit Cloud
f166d1d verified
Raw History Blame Contribute Delete
12.1 kB
"""
Step 1: 从视频目录中提取每个三级文件夹(采集时段)的代表帧
- 遍历所有 L1/L2/L3 结构,找最后一个 MP4,抽取第 0 帧
- 统计该 L3 下所有 MP4 的总时长(分钟)
- 输出图片到 /mnt/nas/Processing_data/Label_cites/ 保持相同目录结构
- 生成索引文件 sessions_index.json 供 step2 使用
- 每个 (L2, 日期) 组生成日代表图:取当天首/中/尾三个 L3 的图片
特殊结构处理:
- ShenZhen: L3 下还有 L4 子文件夹,递归找 MP4
- Haerbin: MP4 直接在 L2(无 L3),L2 本身作为唯一 session
- Hongkong: L2 下有 'video' 子文件夹作为 pseudo-L3
运行:
conda activate nds && python step1_extract_frames.py # 断点续跑
conda activate nds && python step1_extract_frames.py --reset # 清空重新提取
"""
import os
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
import cv2
NUM_WORKERS = os.cpu_count() or 4
VIDEO_ROOT = "/mnt/nas/ADSafety/ADSafety/Aerial_Raw_Videos/2025-Changchun-AerialVideo-V1/"
OUTPUT_ROOT = "/mnt/nas/Processing_data/Label_cites"
INDEX_FILE = "/home/stu6/Projects/LabelWork/sessions_index.json"
VIDEO_EXTS = {".mp4", ".MP4"}
# ─────────────────────────── 工具函数 ────────────────────────────
def find_mp4s(folder: Path) -> list:
"""递归找 folder 下所有 MP4 文件,按名称排序"""
found = []
for root, _, files in os.walk(folder):
for f in sorted(files):
if Path(f).suffix in VIDEO_EXTS:
found.append(Path(root) / f)
return sorted(found)
def get_video_duration_minutes(video_path: Path) -> float:
"""用 OpenCV 获取视频时长(分钟)"""
try:
cap = cv2.VideoCapture(str(video_path))
fps = cap.get(cv2.CAP_PROP_FPS)
frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
cap.release()
if fps > 0 and frame_count > 0:
return frame_count / fps / 60.0
except Exception:
pass
# 回退:文件大小估算(1GB ≈ 1分钟)
try:
return video_path.stat().st_size / (1024 ** 3)
except Exception:
return 0.0
def extract_first_frame(video_path: Path, output_jpg: Path) -> bool:
"""用 OpenCV 抽取视频第 0 帧为 JPG"""
output_jpg.parent.mkdir(parents=True, exist_ok=True)
if output_jpg.exists():
return True
try:
cap = cv2.VideoCapture(str(video_path))
ret, frame = cap.read()
cap.release()
if ret:
cv2.imwrite(str(output_jpg), frame)
return output_jpg.exists()
except Exception as e:
print(f" [ERROR] OpenCV 失败 {video_path.name}: {e}")
return False
def parse_collection_time(video_name: str) -> str:
"""从文件名解析采集时间,格式:DJI_20250711155546_0001_V → 2025年7月11日 15:55"""
m = re.search(r"(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})", video_name)
if m:
y, mo, d, h, mi = m.groups()
return f"{y}年{int(mo)}月{int(d)}日 {int(h):02d}:{int(mi):02d}"
return ""
def parse_date_str(name: str) -> str:
"""从名称中提取日期字符串 YYYYMMDD,用于按天分组"""
m = re.search(r"(\d{8})", name)
return m.group(1) if m else ""
# ──────────────────────────── 核心处理 ────────────────────────────
def process_session(l1_name: str, l2_name: str, l3_name: str,
session_folder: Path, relative_path: str):
"""
处理一个 session:找最后一个 MP4、提取其第 0 帧、算总时长
返回 session 信息字典,失败返回 None
"""
mp4s = find_mp4s(session_folder)
if not mp4s:
print(f" [SKIP] 无 MP4: {session_folder}")
return None
last_mp4 = mp4s[-1] # 取最后一个视频
video_name = last_mp4.name
output_jpg = Path(OUTPUT_ROOT) / relative_path / (last_mp4.stem + ".jpg")
ok = extract_first_frame(last_mp4, output_jpg)
if not ok:
print(f" [ERROR] 帧提取失败: {relative_path}")
return None
total_duration = sum(get_video_duration_minutes(mp4) for mp4 in mp4s)
collection_time = parse_collection_time(video_name)
return {
"l1": l1_name,
"l2": l2_name,
"l3": l3_name,
"folder_path": relative_path,
"video_name": video_name,
"location_name": l2_name,
"collection_time": collection_time,
"duration": round(total_duration, 4),
"image_path": str(output_jpg),
"mp4_count": len(mp4s),
}
def build_day_summaries(sessions: list) -> list:
"""
对每个 (l1, l2, 日期) 分组,选出首/中/尾三个 session 作为当天代表图。
日期从 l3 名称解析,回退到 video_name。
"""
groups = defaultdict(list)
for s in sessions:
date = parse_date_str(s["l3"]) or parse_date_str(s["video_name"])
groups[(s["l1"], s["l2"], date)].append(s)
summaries = []
for (l1, l2, date), group in sorted(groups.items()):
group = sorted(group, key=lambda s: s["l3"])
n = len(group)
# 选首/中/尾,去重
candidates = [group[0], group[n // 2], group[-1]]
seen, picks = set(), []
for p in candidates:
if p["folder_path"] not in seen:
seen.add(p["folder_path"])
picks.append(p)
date_display = ""
if len(date) == 8:
date_display = f"{date[:4]}年{int(date[4:6])}月{int(date[6:8])}日"
total_duration = round(sum(s["duration"] for s in group), 4)
representatives = [
{
"folder_path": p["folder_path"],
"image_path": p["image_path"],
"collection_time": p["collection_time"],
"duration": p["duration"],
}
for p in picks
]
summaries.append({
"l1": l1,
"l2": l2,
"date": date,
"date_display": date_display,
"session_count": n,
"total_duration": total_duration,
"representatives": representatives,
})
return summaries
# ────────────────────────── 目录扫描 ──────────────────────────────
def collect_tasks(video_root: Path, done_paths: set) -> list:
"""
Phase 1:单线程遍历目录,收集所有待处理任务。
返回 list of (l1_name, l2_name, l3_name, session_folder, rel)
"""
tasks = []
for l1_dir in sorted(video_root.iterdir()):
if not l1_dir.is_dir():
continue
l1_name = l1_dir.name
for l2_dir in sorted(l1_dir.iterdir()):
if not l2_dir.is_dir():
continue
l2_name = l2_dir.name
# ── 特殊情况1:Haerbin —— MP4 直接在 L2,无 L3 ──
mp4s_in_l2 = [f for f in l2_dir.iterdir()
if f.is_file() and f.suffix in VIDEO_EXTS]
if mp4s_in_l2:
rel = f"{l1_name}/{l2_name}"
if rel not in done_paths:
tasks.append((l1_name, l2_name, l2_name, l2_dir, rel))
continue
# ── 特殊情况2:Hongkong —— L2 下有 'video' 子文件夹 ──
video_subdir = l2_dir / "video"
if video_subdir.is_dir():
rel = f"{l1_name}/{l2_name}/video"
if rel not in done_paths:
tasks.append((l1_name, l2_name, "video", video_subdir, rel))
continue
# ── 标准情况:L3 子文件夹 ──
for l3_dir in sorted(d for d in l2_dir.iterdir() if d.is_dir()):
l3_name = l3_dir.name
rel = f"{l1_name}/{l2_name}/{l3_name}"
if rel not in done_paths:
tasks.append((l1_name, l2_name, l3_name, l3_dir, rel))
return tasks
def _worker(args):
"""进程池工作函数"""
l1_name, l2_name, l3_name, session_folder, rel = args
return process_session(l1_name, l2_name, l3_name, session_folder, rel)
# ──────────────────────────── 主流程 ──────────────────────────────
def main():
reset = "--reset" in sys.argv
video_root = Path(VIDEO_ROOT)
if reset:
print("[RESET] 删除已有图片和索引...")
if Path(OUTPUT_ROOT).exists():
jpgs = list(Path(OUTPUT_ROOT).rglob("*.jpg")) + list(Path(OUTPUT_ROOT).rglob("*.JPG"))
for jpg in jpgs:
jpg.unlink(missing_ok=True)
print(f" 已删除 {len(jpgs)} 张图片")
if Path(INDEX_FILE).exists():
Path(INDEX_FILE).unlink()
print(f" 已删除 {INDEX_FILE}")
# 加载已有索引(支持断点续跑)
sessions = []
done_paths = set()
if Path(INDEX_FILE).exists():
with open(INDEX_FILE, encoding="utf-8") as f:
existing = json.load(f)
sessions = existing.get("sessions", existing) if isinstance(existing, dict) else existing
done_paths = {s["folder_path"] for s in sessions}
print(f"[断点续跑] 已有 {len(done_paths)} 条记录")
# Phase 1:收集任务
print("Phase 1: 扫描目录结构...")
tasks = collect_tasks(video_root, done_paths)
print(f" 待处理 session 数: {len(tasks)},使用 {NUM_WORKERS} 个进程")
if tasks:
# Phase 2:并行提取帧
print("Phase 2: 并行提取帧...")
SAVE_INTERVAL = 20
completed = 0
with ProcessPoolExecutor(max_workers=NUM_WORKERS) as executor:
futures = {executor.submit(_worker, t): t[4] for t in tasks}
for future in as_completed(futures):
rel = futures[future]
try:
session = future.result()
except Exception as e:
print(f" [ERROR] {rel}: {e}")
session = None
if session:
sessions.append(session)
done_paths.add(rel)
print(f" [OK] {rel}")
completed += 1
if completed % SAVE_INTERVAL == 0:
_save_index(sessions)
print(f" [保存] {completed}/{len(tasks)} 完成")
# Phase 3:生成日代表图索引
print("Phase 3: 生成日代表图索引...")
day_summaries = build_day_summaries(sessions)
print(f" 共 {len(day_summaries)} 个 (场景, 日期) 组合")
_save_index(sessions, day_summaries)
# 统计汇报
print(f"\n✅ 完成: {len(sessions)} 个 sessions,{len(day_summaries)} 个日期组")
by_l1 = defaultdict(lambda: {"count": 0, "duration": 0.0})
for s in sessions:
by_l1[s["l1"]]["count"] += 1
by_l1[s["l1"]]["duration"] += s["duration"]
for k in sorted(by_l1):
print(f" {k}: {by_l1[k]['count']} sessions, {by_l1[k]['duration']:.1f} 分钟")
def _save_index(sessions: list, day_summaries: list = None):
data = {"sessions": sessions}
if day_summaries is not None:
data["day_summaries"] = day_summaries
elif Path(INDEX_FILE).exists():
# 保留已有的 day_summaries
with open(INDEX_FILE, encoding="utf-8") as f:
old = json.load(f)
if isinstance(old, dict) and "day_summaries" in old:
data["day_summaries"] = old["day_summaries"]
with open(INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
if __name__ == "__main__":
main()