Files
firmware-lowlevel-development/skills/firmware-lowlevel-psp-development/scripts/psp/audit_cv181x_media_ai.py
T

264 lines
13 KiB
Python

#!/usr/bin/env python3
"""Read-only CV181X Release/default/PSP configuration inventory and graph audit."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
from functools import lru_cache
from pathlib import Path
def digest(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def git_provenance(root: Path | None):
if root is None:
return None
def git(*args):
return subprocess.run(["git", "-C", str(root), *args], capture_output=True,
text=True, check=True).stdout.strip()
return {"path": str(root.resolve()), "commit": git("rev-parse", "HEAD"),
"dirty_paths": git("status", "--short", "--untracked-files=all").splitlines()}
def load(path: Path):
if not path.is_file() or path.is_symlink():
raise ValueError(f"需要普通 JSON 文件: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def leaves(value, prefix=""):
if isinstance(value, dict):
for key, item in value.items():
yield from leaves(item, f"{prefix}.{key}" if prefix else key)
elif isinstance(value, list):
for index, item in enumerate(value):
yield from leaves(item, f"{prefix}[{index}]")
else:
yield prefix, value
def config_files(profile: Path, root_name: str):
paths = [profile / root_name, *sorted((profile / "configs").glob("*.json"))]
if root_name == "profile.json":
paths.append(profile / "AiPlugin.json")
if not paths[0].is_file() or not (profile / "configs").is_dir():
raise ValueError(f"Release profile 不完整: {profile}")
return {path.relative_to(profile).as_posix(): path for path in paths if path.is_file()}
@lru_cache(maxsize=2)
def source_index(source_root: Path, profile_name: str) -> dict[str, list[str]]:
index: dict[str, list[str]] = {}
pattern = re.compile(r'\[\s*"([^"]+)"\s*\]')
chip_root = source_root / "chip/cvitek/cv181x"
build = source_root / "profile" / profile_name / "build.cmake"
selected = set()
if build.is_file():
build_text = build.read_text(encoding="utf-8", errors="replace")
for variant in re.findall(r'aux_source_directory\([^\n]*/chip/\$\{ChipManufactory\}/\$\{ChipSerial\}/([^\s)]+)', build_text):
selected.add(profile_name if variant == "${Profile}" else variant)
paths = set(chip_root.glob("*.cpp"))
for variant in selected:
paths.update((chip_root / variant).glob("*.cpp"))
for path in sorted(paths):
for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
for key in pattern.findall(line):
index.setdefault(key, []).append(f"{path.relative_to(source_root)}:{number}")
return index
def source_hits(source_root: Path | None, profile_name: str, key: str) -> list[str]:
return source_index(source_root, profile_name).get(key, [])[:12] if source_root is not None else []
def profile_inventory(profile: Path, root_name: str, source_profile: Path | None, source_root: Path | None):
result = {}
drift = []
for relative, path in config_files(profile, root_name).items():
source_path = source_profile / relative if source_profile and relative != "AiPlugin.json" else None
if source_path is not None and (not source_path.is_file() or digest(path) != digest(source_path)):
drift.append(relative)
rows = []
for field, default in leaves(load(path)):
key = re.split(r"\.|\[", field)[-1]
rows.append({
"field": field,
"release_default": default,
"type": type(default).__name__,
"source_hits_hint_only": source_hits(source_root, profile.name, key),
})
result[relative] = {"sha256": digest(path), "fields": rows}
return result, drift
def check_graph(media_system, media_files, ai_files):
errors = []
if not isinstance(media_system, list) or not media_system:
return ["system.json 必须包含至少一个 System 通道"]
for system_index, system in enumerate(media_system):
groups = {g["vpssGrp"]: g for g in system.get("vpss", {}).get("group", [])}
pools = len(system.get("vb", []))
for group_id, group in groups.items():
for attach in group.get("attachPool", []):
if attach.get("chn", -1) not in range(len(group.get("chn", []))):
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.chn 不存在")
if attach.get("pool", -1) not in range(pools):
errors.append(f"System[{system_index}] VPSS {group_id} attachPool.pool 不存在")
for bind in group.get("bind", []):
parent = groups.get(bind.get("group"))
if parent is None or bind.get("chn", -1) not in range(len(parent.get("chn", []))):
errors.append(f"System[{system_index}] VPSS {group_id} bind 来源不存在")
for relative in ("configs/venc.json", "configs/snap.json", "configs/osd.json", "configs/draw.json"):
for index, item in enumerate(media_files.get(relative, [])):
attr = item.get("attr", item)
group_id = attr.get("VpssGrp")
channel = attr.get("VpssChn")
if group_id is not None and (group_id not in groups or channel not in range(len(groups[group_id].get("chn", [])))):
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
enabled = ai_files.get("configs/config.json", {})
for relative, items in ai_files.items():
if relative == "configs/config.json" or not isinstance(items, list):
continue
module = Path(relative).stem
if enabled.get(module) is not True:
continue
for index, item in enumerate(items):
if not isinstance(item, dict) or "vpssGrp" not in item:
continue
group_id, channel = item["vpssGrp"], item.get("vpssChn")
if group_id not in groups or channel not in range(len(groups[group_id].get("chn", []))):
errors.append(f"{relative}[{index}] VPSS {group_id}/{channel} 不存在")
return errors
def model_references(ai_files, ai_meta, psp=None):
enabled = ai_files.get("configs/config.json", {})
declared = set(ai_meta.get("runtime", {}).get("file", []))
result = []
for relative, items in ai_files.items():
if not relative.startswith("configs/") or not isinstance(items, list):
continue
module = Path(relative).stem
if enabled.get(module) is not True:
continue
for index, item in enumerate(items):
if not isinstance(item, dict):
continue
for key, value in item.items():
if not key.endswith("ModelPath") and key != "modelPath":
continue
if not isinstance(value, str) or not value:
continue
local = (psp / "AiPlugin" / value).is_file() if psp is not None else False
result.append({"module": module, "channel": index, "field": key,
"model": value, "release_runtime_declared": value in declared,
"psp_file_present": local,
"requires_source_or_packaging_evidence": value not in declared and not local})
return result
def audit(media_profile: Path, ai_profile: Path, media_source: Path | None = None,
ai_source: Path | None = None, psp: Path | None = None):
media_profile, ai_profile = media_profile.resolve(), ai_profile.resolve()
if not media_profile.parent.name.startswith("EmxMedia-") or not ai_profile.parent.name.startswith("EmxAiPlugin-"):
raise ValueError("必须指定精确 Release 版本下的 profile,不能指定源码 profile 或 Debug")
media_root = media_profile.parent.name
ai_root = ai_profile.parent.name
media_meta = load(media_profile / "configs.json")
ai_meta = load(ai_profile / "profile.json")
ai_package = load(ai_profile / "AiPlugin.json")
if any(meta.get("ChipSerial", "").lower() != "cv181x" for meta in (media_meta, ai_meta)):
raise ValueError("当前只支持 CV181X Release")
media_source_profile = media_source / "profile" / media_profile.name if media_source else None
ai_source_profile = ai_source / "profile" / ai_profile.name if ai_source else None
media_catalog, media_drift = profile_inventory(media_profile, "configs.json", media_source_profile, media_source)
ai_catalog, ai_drift = profile_inventory(ai_profile, "profile.json", ai_source_profile, ai_source)
errors = []
for key in ("EmxSdkVersion", "ChipManufactory", "ChipSerial", "ChipSdkVersion", "Cross"):
if media_meta.get(key) != ai_meta.get(key):
errors.append(f"Media/AI {key} 不一致")
if ai_package.get("profile") != ai_profile.name or ai_package.get("version") != ai_root.removeprefix("EmxAiPlugin-"):
errors.append("AiPlugin.json 与所选 Release/profile 不一致")
media_files = {rel: load(path) for rel, path in config_files(media_profile, "configs.json").items()}
ai_files = {rel: load(path) for rel, path in config_files(ai_profile, "profile.json").items()}
errors += check_graph(media_files["configs/system.json"], media_files, ai_files)
candidate = []
model_refs = model_references(ai_files, ai_meta, psp)
if psp is not None:
psp = psp.resolve()
psp_json = load(psp / "psp.json")
for key, expected in (("EmxMediaVersion", media_root), ("EmxMediaProfile", media_profile.name),
("EmxAiPluginVersion", ai_root), ("EmxAiPluginProfile", ai_profile.name)):
if psp_json.get(key) != expected:
errors.append(f"psp.json {key} 与所选 Release 不一致")
mapping = (("media", media_files, media_catalog, psp / "configs/media/scene/default"),
("ai", ai_files, ai_catalog, psp / "AiPlugin/configs"))
effective = {"media": dict(media_files), "ai": dict(ai_files)}
for kind, defaults, catalog, target in mapping:
for relative, baseline in defaults.items():
if not relative.startswith("configs/"):
continue
path = target / Path(relative).name
if not path.is_file():
if kind == "ai":
path = psp / "AiPlugin/configs_ro" / Path(relative).name
if not path.is_file():
candidate.append({"kind": kind, "file": relative, "state": "not_in_psp_check_effective_source"})
continue
current = load(path)
effective[kind][relative] = current
baseline_fields, current_fields = dict(leaves(baseline)), dict(leaves(current))
for field in sorted(baseline_fields.keys() | current_fields.keys()):
if field not in baseline_fields or field not in current_fields or baseline_fields[field] != current_fields[field]:
candidate.append({"kind": kind, "file": str(path), "field": field,
"release_default": baseline_fields.get(field),
"candidate": current_fields.get(field),
"state": "changed_or_missing_requires_evidence"})
errors += check_graph(effective["media"]["configs/system.json"], effective["media"], effective["ai"])
model_refs = model_references(effective["ai"], ai_meta, psp)
return {
"schema_version": 1,
"scope": "read_only_static_audit_not_hardware_or_runtime_validation",
"media_release": str(media_profile), "ai_release": str(ai_profile),
"media_sha256": digest(media_profile / "configs.json"),
"ai_sha256": digest(ai_profile / "profile.json"),
"source_release_drift": {"media": media_drift, "ai": ai_drift},
"source_git": {"media": git_provenance(media_source), "ai": git_provenance(ai_source)},
"field_counts": {"media": sum(len(x["fields"]) for x in media_catalog.values()),
"ai": sum(len(x["fields"]) for x in ai_catalog.values())},
"catalog": {"media": media_catalog, "ai": ai_catalog},
"candidate_differences": candidate,
"enabled_ai_model_references": model_refs,
"errors": errors,
"passed_static_cross_checks": not errors,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--media-release-profile", type=Path, required=True)
parser.add_argument("--ai-release-profile", type=Path, required=True)
parser.add_argument("--media-source", type=Path)
parser.add_argument("--ai-source", type=Path)
parser.add_argument("--psp", type=Path)
args = parser.parse_args()
try:
result = audit(args.media_release_profile, args.ai_release_profile,
args.media_source, args.ai_source, args.psp)
except (OSError, ValueError, KeyError, TypeError, json.JSONDecodeError,
subprocess.CalledProcessError) as exc:
parser.exit(2, f"audit failed: {exc}\n")
print(json.dumps(result, ensure_ascii=False, indent=2))
if result["errors"]:
parser.exit(1)
if __name__ == "__main__":
main()