Files
Video-Tree-TRM5/tools/convert_flat_to_treeindex.py

366 lines
11 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""一次性格式转换:TRM4 flat tree.json -> TRM5 TreeIndex JSON。
用法: python tools/convert_flat_to_treeindex.py <src_dir> <dst_dir>
遍历 src_dir 下每个 video_id 子目录中的 tree.jsonTRM4 flat 格式),
转换为 TRM5 TreeIndex 嵌套格式并写入 dst_dir 对应子目录。
app/core/adapters 不 import 此脚本。迁移完成后归档至 tools/archived/。
"""
from __future__ import annotations
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
# ---------------------------------------------------------------------------
# Card 字段默认值(处理 TRM4 可能缺失的字段)
# ---------------------------------------------------------------------------
_L3_CARD_DEFAULTS: dict[str, Any] = {
"frame_summary": "",
"visible_entities": [],
"ongoing_actions": [],
"visible_text": [],
"spatial_layout": "",
"visual_attributes": {},
}
_L2_CARD_DEFAULTS: dict[str, Any] = {
"event_description": "",
"entities": [],
"actions": [],
"action_subjects": [],
"visible_text": [],
"spatial_relations": "",
"state_changes": None,
}
_L1_CARD_DEFAULTS: dict[str, Any] = {
"scene_summary": "",
"main_setting": "",
"key_entities": [],
"main_actions": [],
"topic_keywords": [],
"visible_text": [],
"temporal_flow": "",
}
# ---------------------------------------------------------------------------
# Card 构建辅助
# ---------------------------------------------------------------------------
def _build_card(raw_card: dict[str, Any], defaults: dict[str, Any]) -> dict[str, Any]:
"""从 TRM4 原始 card 字典构建 TRM5 card,缺失字段用默认值填充。
参数:
raw_card: TRM4 tree.json 中节点的 card 字典。
defaults: 该层级的默认值字典。
返回:
仅包含目标字段的 card 字典(字段集合与 defaults 一致)。
"""
return {key: raw_card.get(key, default) for key, default in defaults.items()}
# ---------------------------------------------------------------------------
# 节点转换
# ---------------------------------------------------------------------------
def _convert_l3(
node: dict[str, Any],
video_id: str,
) -> dict[str, Any]:
"""将 TRM4 flat L3 节点转换为 TRM5 嵌套 L3 节点。
参数:
node: TRM4 flat 格式的 L3 节点字典。
video_id: 视频 ID,用于计算 frame_path 的相对路径后缀。
返回:
TRM5 格式的 L3 节点字典。
"""
node_id: str = node["node_id"]
raw_card = node.get("card") or {}
card = _build_card(raw_card, _L3_CARD_DEFAULTS)
# frame_path: frames/{suffix}.jpgsuffix = node_id 去掉 video_id 前缀 + 下划线
prefix = f"{video_id}_"
suffix = node_id[len(prefix) :] if node_id.startswith(prefix) else node_id
frame_path = f"frames/{suffix}.jpg"
return {
"id": node_id,
"card": card,
"timestamp": node.get("frame_timestamp"),
"frame_path": frame_path,
"subtitle": node.get("subtitle"),
}
def _convert_l2(
node: dict[str, Any],
l3_children: list[dict[str, Any]],
) -> dict[str, Any]:
"""将 TRM4 flat L2 节点转换为 TRM5 嵌套 L2 节点。
参数:
node: TRM4 flat 格式的 L2 节点字典。
l3_children: 已转换的 L3 子节点列表(按 time_range 排序)。
返回:
TRM5 格式的 L2 节点字典。
"""
raw_card = node.get("card") or {}
card = _build_card(raw_card, _L2_CARD_DEFAULTS)
time_range = node.get("time_range")
return {
"id": node["node_id"],
"card": card,
"time_range": time_range,
"children": l3_children,
}
def _convert_l1(
node: dict[str, Any],
l2_children: list[dict[str, Any]],
) -> dict[str, Any]:
"""将 TRM4 flat L1 节点转换为 TRM5 嵌套 L1 节点。
参数:
node: TRM4 flat 格式的 L1 节点字典。
l2_children: 已转换的 L2 子节点列表(按 time_range 排序)。
返回:
TRM5 格式的 L1 节点字典。
"""
raw_card = node.get("card") or {}
card = _build_card(raw_card, _L1_CARD_DEFAULTS)
time_range = node.get("time_range")
return {
"id": node["node_id"],
"card": card,
"time_range": time_range,
"children": l2_children,
}
# ---------------------------------------------------------------------------
# 排序辅助
# ---------------------------------------------------------------------------
def _sort_key_time_range(node: dict[str, Any]) -> float:
"""按 time_range 的起始时间排序。
参数:
node: TRM4 节点字典。
返回:
起始时间(float),无 time_range 时返回 0.0。
"""
tr = node.get("time_range")
if tr and len(tr) >= 1:
return float(tr[0])
return 0.0
def _sort_key_timestamp(converted: dict[str, Any]) -> float:
"""按 timestamp 排序(L3 转换后的字典)。
参数:
converted: 已转换的 TRM5 L3 节点字典。
返回:
timestampfloat),无值时返回 0.0。
"""
ts = converted.get("timestamp")
return float(ts) if ts is not None else 0.0
# ---------------------------------------------------------------------------
# 单棵树转换
# ---------------------------------------------------------------------------
def convert_single_tree(flat_data: dict[str, Any], source_path: str) -> dict[str, Any]:
"""将单个 TRM4 flat tree.json 转换为 TRM5 TreeIndex 字典。
参数:
flat_data: TRM4 flat tree.json 解析后的字典。
source_path: 原始数据路径(写入 metadata.source_path)。
返回:
TRM5 TreeIndex 格式的字典(可直接 json.dump 或传入 TreeIndex.from_dict)。
异常:
ValueError: 无法从 flat_data 中提取 video_id。
"""
video_id = flat_data.get("video_id") or flat_data.get("videoID")
if not video_id:
raise ValueError("flat tree.json 中缺少 video_id / videoID 字段")
nodes: dict[str, dict[str, Any]] = flat_data.get("nodes", {})
# Phase 1: 按层级分组
l1_nodes: list[dict[str, Any]] = []
l2_nodes: list[dict[str, Any]] = []
l3_nodes: list[dict[str, Any]] = []
for node in nodes.values():
level = node.get("level")
if level == 1:
l1_nodes.append(node)
elif level == 2:
l2_nodes.append(node)
elif level == 3:
l3_nodes.append(node)
# Phase 2: 构建 parent -> children 映射
# L3 按 parent_id 分组
l3_by_parent: dict[str, list[dict[str, Any]]] = {}
for n in l3_nodes:
pid = n.get("parent_id", "")
l3_by_parent.setdefault(pid, []).append(n)
# L2 按 parent_id 分组
l2_by_parent: dict[str, list[dict[str, Any]]] = {}
for n in l2_nodes:
pid = n.get("parent_id", "")
l2_by_parent.setdefault(pid, []).append(n)
# Phase 3: 自底向上构建嵌套结构
# 转换 L2 -> 附带转换后的 L3 children
converted_l2_by_id: dict[str, dict[str, Any]] = {}
for l2 in l2_nodes:
l2_id = l2["node_id"]
raw_l3_children = l3_by_parent.get(l2_id, [])
# 先转换 L3,再按 timestamp 排序
converted_l3 = [_convert_l3(n, video_id) for n in raw_l3_children]
converted_l3.sort(key=_sort_key_timestamp)
converted_l2_by_id[l2_id] = _convert_l2(l2, converted_l3)
# 转换 L1 -> 附带转换后的 L2 children
roots: list[dict[str, Any]] = []
l1_nodes.sort(key=_sort_key_time_range)
for l1 in l1_nodes:
l1_id = l1["node_id"]
raw_l2_children = l2_by_parent.get(l1_id, [])
raw_l2_children.sort(key=_sort_key_time_range)
l2_children = [converted_l2_by_id[n["node_id"]] for n in raw_l2_children]
roots.append(_convert_l1(l1, l2_children))
# Phase 4: 构建 TreeIndex 字典
return {
"metadata": {
"source_path": source_path,
"modality": "video",
"created_at": datetime.now().isoformat(),
},
"roots": roots,
}
# ---------------------------------------------------------------------------
# 批量转换入口
# ---------------------------------------------------------------------------
def convert_directory(src_dir: str, dst_dir: str) -> tuple[int, int]:
"""批量转换目录下所有 TRM4 tree.json 到 TRM5 TreeIndex 格式。
参数:
src_dir: 源目录(TRM4 store/videos/),其下每个子目录含 tree.json。
dst_dir: 目标目录(TRM5 store/videos/),保持同名子目录结构。
返回:
(成功数, 失败数) 元组。
"""
src_path = Path(src_dir)
dst_path = Path(dst_dir)
if not src_path.is_dir():
print(f"错误: 源目录不存在: {src_dir}", file=sys.stderr)
sys.exit(1)
success_count = 0
fail_count = 0
tree_files = sorted(src_path.glob("*/tree.json"))
total = len(tree_files)
print(f"发现 {total} 个 tree.json 待转换")
for idx, tree_file in enumerate(tree_files, 1):
video_id = tree_file.parent.name
out_dir = dst_path / video_id
out_file = out_dir / "tree.json"
try:
with open(tree_file, encoding="utf-8") as f:
flat_data = json.load(f)
result = convert_single_tree(flat_data, source_path=str(tree_file))
out_dir.mkdir(parents=True, exist_ok=True)
with open(out_file, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
# 统计节点数
n_l1 = len(result["roots"])
n_l2 = sum(len(r["children"]) for r in result["roots"])
n_l3 = sum(len(l2["children"]) for r in result["roots"] for l2 in r["children"])
print(f"[{idx}/{total}] {video_id}: L1={n_l1}, L2={n_l2}, L3={n_l3}")
success_count += 1
except Exception as e:
print(f"[{idx}/{total}] {video_id}: 失败 - {e}", file=sys.stderr)
fail_count += 1
return success_count, fail_count
# ---------------------------------------------------------------------------
# CLI 入口
# ---------------------------------------------------------------------------
def main() -> None:
"""CLI 入口:解析参数并执行批量转换。"""
if len(sys.argv) != 3:
print(
"用法: python tools/convert_flat_to_treeindex.py <src_dir> <dst_dir>",
file=sys.stderr,
)
print(" src_dir: TRM4 store/videos/ 目录(含 video_id/tree.json", file=sys.stderr)
print(" dst_dir: TRM5 store/videos/ 目标目录", file=sys.stderr)
sys.exit(1)
src_dir = sys.argv[1]
dst_dir = sys.argv[2]
print(f"源目录: {src_dir}")
print(f"目标目录: {dst_dir}")
print()
success, fail = convert_directory(src_dir, dst_dir)
print()
print(f"转换完成: 成功 {success}, 失败 {fail}")
if fail > 0:
sys.exit(1)
if __name__ == "__main__":
main()