feat(tree/repair): 检测器扩展 — visible_entities/ongoing_actions/spatial_layout 为空也触发修复
This commit is contained in:
@@ -0,0 +1,153 @@
|
|||||||
|
"""树修复检测器:扫描 TreeIndex 识别缺失/低质量节点。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.tree.index import TreeIndex
|
||||||
|
|
||||||
|
# 相邻 L2 片段之间允许的最大时间间隙(秒)
|
||||||
|
_MAX_TIME_GAP_S = 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NodeIssue:
|
||||||
|
"""检测到的节点问题。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
node_id: 问题节点 ID。
|
||||||
|
level: 节点层级(1/2/3)。
|
||||||
|
issue_type: 问题类型。
|
||||||
|
details: 详细描述。
|
||||||
|
"""
|
||||||
|
|
||||||
|
node_id: str
|
||||||
|
level: int
|
||||||
|
issue_type: str # "empty_field" | "missing_frame" | "no_children" | "time_gap"
|
||||||
|
details: str
|
||||||
|
|
||||||
|
|
||||||
|
def detect_issues(
|
||||||
|
index: TreeIndex,
|
||||||
|
frames_dir: Path | None = None,
|
||||||
|
) -> list[NodeIssue]:
|
||||||
|
"""扫描树,返回所有问题节点列表。
|
||||||
|
|
||||||
|
检查项:
|
||||||
|
- L3: card 必填字段为空(frame_summary / visible_entities / ongoing_actions / spatial_layout)
|
||||||
|
- L3: frame_path 对应文件不存在(需提供 frames_dir)
|
||||||
|
- L2/L1: children 列表为空
|
||||||
|
- L2: 相邻 clips 时间范围不连续(gap > 1秒)
|
||||||
|
|
||||||
|
参数:
|
||||||
|
index: 待检测的 TreeIndex。
|
||||||
|
frames_dir: 帧文件根目录(可选,提供时检查帧文件存在性)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
问题列表,按 level 降序(L3 → L2 → L1)排列。
|
||||||
|
"""
|
||||||
|
issues: list[NodeIssue] = []
|
||||||
|
|
||||||
|
for l1 in index.roots:
|
||||||
|
# L1: children 不为空
|
||||||
|
if not l1.children:
|
||||||
|
issues.append(
|
||||||
|
NodeIssue(
|
||||||
|
node_id=l1.id,
|
||||||
|
level=1,
|
||||||
|
issue_type="no_children",
|
||||||
|
details="L1 节点无 L2 子节点",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# L2: 相邻 clips 时间间隙检查
|
||||||
|
_check_time_gaps(l1.children, issues)
|
||||||
|
|
||||||
|
for l2 in l1.children:
|
||||||
|
# L2: children 不为空
|
||||||
|
if not l2.children:
|
||||||
|
issues.append(
|
||||||
|
NodeIssue(
|
||||||
|
node_id=l2.id,
|
||||||
|
level=2,
|
||||||
|
issue_type="no_children",
|
||||||
|
details="L2 节点无 L3 子节点",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for l3 in l2.children:
|
||||||
|
# L3: 各必填字段不为空
|
||||||
|
empty_fields: list[str] = []
|
||||||
|
if not l3.card.frame_summary:
|
||||||
|
empty_fields.append("frame_summary")
|
||||||
|
if not l3.card.visible_entities:
|
||||||
|
empty_fields.append("visible_entities")
|
||||||
|
if not l3.card.ongoing_actions:
|
||||||
|
empty_fields.append("ongoing_actions")
|
||||||
|
if not l3.card.spatial_layout:
|
||||||
|
empty_fields.append("spatial_layout")
|
||||||
|
if empty_fields:
|
||||||
|
issues.append(
|
||||||
|
NodeIssue(
|
||||||
|
node_id=l3.id,
|
||||||
|
level=3,
|
||||||
|
issue_type="empty_field",
|
||||||
|
details=f"L3 节点字段为空: {', '.join(empty_fields)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# L3: frame_path 文件存在性
|
||||||
|
if (
|
||||||
|
frames_dir is not None
|
||||||
|
and l3.frame_path is not None
|
||||||
|
and not (frames_dir / l3.frame_path).exists()
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
NodeIssue(
|
||||||
|
node_id=l3.id,
|
||||||
|
level=3,
|
||||||
|
issue_type="missing_frame",
|
||||||
|
details=f"帧文件不存在: {l3.frame_path}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 按 level 降序排列(L3=3 → L2=2 → L1=1)
|
||||||
|
issues.sort(key=lambda i: -i.level)
|
||||||
|
|
||||||
|
logger.info("树缺陷检测完成,发现 {} 个问题", len(issues))
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _check_time_gaps(
|
||||||
|
l2_nodes: list,
|
||||||
|
issues: list[NodeIssue],
|
||||||
|
) -> None:
|
||||||
|
"""检查同一 L1 下相邻 L2 节点之间的时间间隙。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
l2_nodes: 同一 L1 节点下的 L2 子节点列表。
|
||||||
|
issues: 问题列表(原地追加)。
|
||||||
|
"""
|
||||||
|
for i in range(len(l2_nodes) - 1):
|
||||||
|
curr = l2_nodes[i]
|
||||||
|
nxt = l2_nodes[i + 1]
|
||||||
|
if curr.time_range is None or nxt.time_range is None:
|
||||||
|
continue
|
||||||
|
gap = nxt.time_range[0] - curr.time_range[1]
|
||||||
|
if gap > _MAX_TIME_GAP_S:
|
||||||
|
issues.append(
|
||||||
|
NodeIssue(
|
||||||
|
node_id=nxt.id,
|
||||||
|
level=2,
|
||||||
|
issue_type="time_gap",
|
||||||
|
details=f"与前一片段间隙 {gap:.1f}s(阈值 {_MAX_TIME_GAP_S}s)",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"""修复检测器单元测试。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.tree.index import (
|
||||||
|
IndexMeta,
|
||||||
|
L1Card,
|
||||||
|
L1Node,
|
||||||
|
L2Card,
|
||||||
|
L2Node,
|
||||||
|
L3Card,
|
||||||
|
L3Node,
|
||||||
|
TreeIndex,
|
||||||
|
)
|
||||||
|
from app.tree.repair.detector import detect_issues
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _card_l3(summary: str = "正常描述") -> L3Card:
|
||||||
|
return L3Card(summary, ["实体"], ["动作"], [], "居中", {})
|
||||||
|
|
||||||
|
|
||||||
|
def _card_l2() -> L2Card:
|
||||||
|
return L2Card("事件", [], [], [], [], "", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _card_l1() -> L1Card:
|
||||||
|
return L1Card("场景", "", [], [], [], [], "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDetectIssues:
|
||||||
|
def test_healthy_tree_no_issues(self) -> None:
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=_card_l3(), timestamp=1.0)
|
||||||
|
l2 = L2Node(
|
||||||
|
id="l1_0_l2_0",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l3],
|
||||||
|
)
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l2],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
assert detect_issues(index) == []
|
||||||
|
|
||||||
|
def test_empty_frame_summary(self) -> None:
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=_card_l3(""), timestamp=1.0)
|
||||||
|
l2 = L2Node(
|
||||||
|
id="l1_0_l2_0",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l3],
|
||||||
|
)
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l2],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "empty_field" and i.node_id == "l1_0_l2_0_l3_0" for i in issues)
|
||||||
|
|
||||||
|
def test_missing_frame_file(self, tmp_path: Path) -> None:
|
||||||
|
l3 = L3Node(
|
||||||
|
id="l1_0_l2_0_l3_0",
|
||||||
|
card=_card_l3(),
|
||||||
|
timestamp=1.0,
|
||||||
|
frame_path="frames/missing.jpg",
|
||||||
|
)
|
||||||
|
l2 = L2Node(
|
||||||
|
id="l1_0_l2_0",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l3],
|
||||||
|
)
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l2],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index, frames_dir=tmp_path)
|
||||||
|
assert any(i.issue_type == "missing_frame" for i in issues)
|
||||||
|
|
||||||
|
def test_l2_no_children(self) -> None:
|
||||||
|
l2 = L2Node(
|
||||||
|
id="l1_0_l2_0",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[l2],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "no_children" and i.level == 2 for i in issues)
|
||||||
|
|
||||||
|
def test_l1_no_children(self) -> None:
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 10.0),
|
||||||
|
children=[],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "no_children" and i.level == 1 for i in issues)
|
||||||
|
|
||||||
|
def test_empty_visible_entities(self) -> None:
|
||||||
|
"""visible_entities 为空也触发 empty_field。"""
|
||||||
|
card = L3Card("正常描述", [], ["动作"], [], "居中", {})
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=card, timestamp=1.0)
|
||||||
|
l2 = L2Node(id="l1_0_l2_0", card=_card_l2(), time_range=(0.0, 10.0), children=[l3])
|
||||||
|
l1 = L1Node(id="l1_0", card=_card_l1(), time_range=(0.0, 10.0), children=[l2])
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "empty_field" and "visible_entities" in i.details for i in issues)
|
||||||
|
|
||||||
|
def test_empty_ongoing_actions(self) -> None:
|
||||||
|
"""ongoing_actions 为空也触发 empty_field。"""
|
||||||
|
card = L3Card("正常描述", ["实体"], [], [], "居中", {})
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=card, timestamp=1.0)
|
||||||
|
l2 = L2Node(id="l1_0_l2_0", card=_card_l2(), time_range=(0.0, 10.0), children=[l3])
|
||||||
|
l1 = L1Node(id="l1_0", card=_card_l1(), time_range=(0.0, 10.0), children=[l2])
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "empty_field" and "ongoing_actions" in i.details for i in issues)
|
||||||
|
|
||||||
|
def test_empty_spatial_layout(self) -> None:
|
||||||
|
"""spatial_layout 为空也触发 empty_field。"""
|
||||||
|
card = L3Card("正常描述", ["实体"], ["动作"], [], "", {})
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=card, timestamp=1.0)
|
||||||
|
l2 = L2Node(id="l1_0_l2_0", card=_card_l2(), time_range=(0.0, 10.0), children=[l3])
|
||||||
|
l1 = L1Node(id="l1_0", card=_card_l1(), time_range=(0.0, 10.0), children=[l2])
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "empty_field" and "spatial_layout" in i.details for i in issues)
|
||||||
|
|
||||||
|
def test_multiple_empty_fields_single_issue(self) -> None:
|
||||||
|
"""多个字段同时为空只产生一个 issue,details 列出所有空字段。"""
|
||||||
|
card = L3Card("", [], [], [], "", {})
|
||||||
|
l3 = L3Node(id="l1_0_l2_0_l3_0", card=card, timestamp=1.0)
|
||||||
|
l2 = L2Node(id="l1_0_l2_0", card=_card_l2(), time_range=(0.0, 10.0), children=[l3])
|
||||||
|
l1 = L1Node(id="l1_0", card=_card_l1(), time_range=(0.0, 10.0), children=[l2])
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = [i for i in detect_issues(index) if i.issue_type == "empty_field"]
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "frame_summary" in issues[0].details
|
||||||
|
assert "visible_entities" in issues[0].details
|
||||||
|
|
||||||
|
def test_time_gap(self) -> None:
|
||||||
|
l3_a = L3Node(id="l1_0_l2_0_l3_0", card=_card_l3(), timestamp=1.0)
|
||||||
|
l3_b = L3Node(id="l1_0_l2_1_l3_0", card=_card_l3(), timestamp=20.0)
|
||||||
|
l2_a = L2Node(
|
||||||
|
id="l1_0_l2_0",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(0.0, 5.0),
|
||||||
|
children=[l3_a],
|
||||||
|
)
|
||||||
|
l2_b = L2Node(
|
||||||
|
id="l1_0_l2_1",
|
||||||
|
card=_card_l2(),
|
||||||
|
time_range=(15.0, 25.0),
|
||||||
|
children=[l3_b],
|
||||||
|
)
|
||||||
|
l1 = L1Node(
|
||||||
|
id="l1_0",
|
||||||
|
card=_card_l1(),
|
||||||
|
time_range=(0.0, 25.0),
|
||||||
|
children=[l2_a, l2_b],
|
||||||
|
)
|
||||||
|
index = TreeIndex(metadata=IndexMeta("/t.mp4", "video"), roots=[l1])
|
||||||
|
issues = detect_issues(index)
|
||||||
|
assert any(i.issue_type == "time_gap" for i in issues)
|
||||||
Reference in New Issue
Block a user