359 lines
21 KiB
Python
359 lines
21 KiB
Python
"""VIDEO-001–005: проверка программного видеотракта без аппаратных лабораторий и RF."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import ast
|
||
import argparse
|
||
import hashlib
|
||
import importlib
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
|
||
import av
|
||
import numpy as np
|
||
|
||
from tools.video003_design import ROOT, run as calculate_design
|
||
|
||
|
||
MODULES = (
|
||
"protocol.packet", "protocol.bpsk_radio", "protocol.convolutional_fec",
|
||
"protocol.video_stream_transport", "protocol.punctured_fec",
|
||
"protocol.video003_modem", "protocol.video003_compact_header",
|
||
"protocol.video004_viterbi", "protocol.video004_erasure",
|
||
"tools.video_compare", "tools.video002_radio_budget",
|
||
"tools.video002_simulation", "tools.video003_design",
|
||
"tools.video003_synthetic", "tools.video003_stream",
|
||
"tools.video003_codec", "tools.video003_transport",
|
||
"tools.video004_benchmark", "tools.video004_transport",
|
||
"tools.video004_interleaving",
|
||
"tools.video004_viterbi_benchmark", "tools.video004_longrun",
|
||
"tools.video004_pipeline_benchmark",
|
||
"tools.video004_keyframe", "tools.video004_seed_sweep",
|
||
"protocol.video005_iq_waveform", "protocol.video005_timing",
|
||
"protocol.video005_carrier", "protocol.video005_receiver",
|
||
"tools.video005_channel", "tools.video005_video", "tools.video005_benchmark",
|
||
)
|
||
TESTS = (
|
||
"tests/test_packet.py", "tests/test_bpsk_radio.py",
|
||
"tests/test_video_001_compare.py", "tests/test_video002_codec.py",
|
||
"tests/test_video002_transport.py", "tests/test_video003_modem.py",
|
||
"tests/test_video003_design.py", "tests/test_video003_transport.py",
|
||
"tests/test_video003_stream.py",
|
||
"tests/test_video004_viterbi.py", "tests/test_video004_erasure.py",
|
||
"tests/test_video004_transport.py",
|
||
"tests/test_video004_pipeline.py",
|
||
"tests/test_video004_keyframe.py",
|
||
"tests/test_video005_iq.py", "tests/test_video005_video.py",
|
||
)
|
||
STATIC_PATHS = (
|
||
"protocol/packet.py", "protocol/video_packet.py", "protocol/bpsk_radio.py",
|
||
"protocol/convolutional_fec.py", "protocol/packet_erasure_fec.py",
|
||
"protocol/video_stream_transport.py", "protocol/punctured_fec.py", "protocol/video003_modem.py",
|
||
"protocol/video003_compact_header.py", "protocol/video004_viterbi.py",
|
||
"protocol/video004_erasure.py", "protocol/video005_iq_waveform.py",
|
||
"protocol/video005_timing.py", "protocol/video005_carrier.py",
|
||
"protocol/video005_receiver.py", "tools/video005_channel.py",
|
||
"tools/video005_video.py", "tools/video005_benchmark.py", "tools/video_gate.py",
|
||
"tests/test_video005_iq.py", "tests/test_video005_video.py",
|
||
)
|
||
ARTIFACTS = {
|
||
"video001_160": "data/processed/video001/comparison_traffic_160k.json",
|
||
"video001_320": "data/processed/video001/comparison_traffic_320k.json",
|
||
"video002": "data/processed/video002/results.json",
|
||
"design": "data/processed/video003/design_space.json",
|
||
"synthetic": "data/processed/video003/synthetic_phy.json",
|
||
"stream": "data/processed/video003/continuous_stream.json",
|
||
"codec": "data/processed/video003/codec_results.json",
|
||
"transport": "data/processed/video003/transport_results.json",
|
||
"benchmark_manifest": "data/processed/video004/canonical_manifest.json",
|
||
"benchmark_before": "data/processed/video004/benchmark_before.json",
|
||
"benchmark_after": "data/processed/video004/benchmark_after.json",
|
||
"transport004": "data/processed/video004/transport_results.json",
|
||
"interleaving004": "data/processed/video004/interleaving_results.json",
|
||
"viterbi004": "data/processed/video004/viterbi_benchmark.json",
|
||
"long004": "data/processed/video004/benchmark_256.json",
|
||
"pipeline_manifest004": "data/processed/video004/pipeline_manifest.json",
|
||
"pipeline004": "data/processed/video004/pipeline_benchmark.json",
|
||
"keyframe004": "data/processed/video004/keyframe_recovery.json",
|
||
"seed_sweep004": "data/processed/video004/normal_seed_sweep.json",
|
||
"benchmark005": "data/processed/video005/benchmark.json",
|
||
}
|
||
FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhackrf"}
|
||
|
||
|
||
def _check_imports_and_hardware_boundary() -> None:
|
||
for name in MODULES:
|
||
module = importlib.import_module(name)
|
||
if module.__file__ is None:
|
||
raise AssertionError(f"у модуля нет исходного файла: {name}")
|
||
source_path = Path(module.__file__).resolve()
|
||
if not source_path.is_relative_to(ROOT.resolve()):
|
||
raise AssertionError(f"локальный модуль вне worktree: {name}")
|
||
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
|
||
for node in ast.walk(tree):
|
||
imported = []
|
||
if isinstance(node, ast.Import):
|
||
imported = [alias.name for alias in node.names]
|
||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||
imported = [node.module]
|
||
for imported_name in imported:
|
||
if imported_name.split(".")[0] in FORBIDDEN_IMPORT_ROOTS:
|
||
raise AssertionError(f"аппаратный импорт {imported_name} в {name}")
|
||
|
||
|
||
def _load_artifacts() -> dict[str, dict]:
|
||
documents = {}
|
||
for label, relative in ARTIFACTS.items():
|
||
path = ROOT / relative
|
||
if not path.is_file():
|
||
raise AssertionError(f"нет результата: {relative}")
|
||
documents[label] = json.loads(path.read_text(encoding="utf-8"))
|
||
source = ROOT / "data/raw/video001/udacity_project_video.mp4"
|
||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||
for label in ("video001_160", "video001_320", "video002", "codec", "transport"):
|
||
if documents[label]["source_sha256"] != digest:
|
||
raise AssertionError(f"SHA исходного ролика не совпал: {label}")
|
||
return documents
|
||
|
||
|
||
def _check_results(documents: dict[str, dict]) -> None:
|
||
design = documents["design"]
|
||
if design != calculate_design():
|
||
raise AssertionError("расчётная таблица отличается от сохранённой")
|
||
if design["best_calculated"]["useful_video_rate_bps"] >= 600_000:
|
||
raise AssertionError("таблица неверно объявила достижение 600 кбит/с")
|
||
synthetic = documents["synthetic"]
|
||
if len(synthetic["records"]) != 7 * 4 * synthetic["trials_per_point"]:
|
||
raise AssertionError("число реализаций AWGN не совпадает")
|
||
if len(synthetic["summary"]) != 28:
|
||
raise AssertionError("неполная сводка AWGN")
|
||
if not all(item["packet_crc_pass"] and item["packet_exact"]
|
||
for item in synthetic["rrc_controls"]):
|
||
raise AssertionError("RRC-контроль не прошёл")
|
||
if not all(item["crc_pass_count"] == item["trials"]
|
||
for item in synthetic["summary"] if item["ebn0_db"] == 9.0):
|
||
raise AssertionError("эталонный AWGN-контроль 9 dB изменился")
|
||
stream = documents["stream"]
|
||
if len(stream["results"]) != 2 or stream["packet_count"] != 32:
|
||
raise AssertionError("неполный непрерывный поток")
|
||
for item in stream["results"]:
|
||
if (item["crc_pass_count"] != 32 or item["exact_packet_count"] != 32
|
||
or item["verified_video_fragment_count"] != 32
|
||
or abs(item["offline_verified_video_bps"]
|
||
- item["calculated_zero_loss_video_bps"]) > 1e-6):
|
||
raise AssertionError("непрерывный поток не совпал с расчётом")
|
||
codec = documents["codec"]
|
||
if len(codec["cases"]) != 60 or av.__version__ != "16.0.1":
|
||
raise AssertionError("неполный кодековый опыт или иная версия PyAV")
|
||
for case in codec["cases"]:
|
||
settings = case["settings"]
|
||
if (settings["ffmpeg_cli_used"] or settings["profile_actual"] is None
|
||
or settings["codec_name"] != "libx264"
|
||
or case["keyframe_positions"][0] != 0
|
||
or case["frame_count"] != len(case["frame_sizes_bytes"])):
|
||
raise AssertionError("неполная конфигурация или размеры H.264")
|
||
calculated_average = sum(case["frame_sizes_bytes"]) * 8 * case["settings"]["frame_rate"] / case["frame_count"]
|
||
if abs(calculated_average - case["actual_average_bitrate_bps"]) > 1e-6:
|
||
raise AssertionError("средний поток H.264 не совпал с размерами кадров")
|
||
for case in (item for item in codec["cases"] if item["profile"] == "CURRENT"):
|
||
originals = [item for item in documents["video001_320"]["cases"]
|
||
if item["window"] == case["window"]
|
||
and item["resolution"] == case["settings"]["resolution"]
|
||
and item["fps"] == case["settings"]["frame_rate"]
|
||
and item["codec"] == "H264_LIBX264"]
|
||
if len(originals) != 1 or case["frame_sizes_bytes"] != originals[0]["frame_sizes_bytes"]:
|
||
raise AssertionError("CURRENT не повторил покадровые размеры VIDEO-001")
|
||
transport = documents["transport"]
|
||
if len(transport["cases"]) != 784:
|
||
raise AssertionError("неполная матрица доставки")
|
||
for case in transport["cases"]:
|
||
if not case["no_old_frame_replay"]:
|
||
raise AssertionError("повторно показан старый кадр")
|
||
if (case["p95_end_to_end_age_ms"] is not None
|
||
and case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]):
|
||
raise AssertionError("P95 возраста больше максимума")
|
||
selected_clean = [case for case in transport["cases"]
|
||
if case["codec_profile"] == "SHORT_GOP_400K"
|
||
and case["link"] == "QPSK_500_R23_1012_0MS"
|
||
and case["scenario"] == "random" and case["level"] == 0]
|
||
if len(selected_clean) != 6 or any(case["displayed_frames"] != case["input_frames"]
|
||
for case in selected_clean):
|
||
raise AssertionError("контрольный видеотракт не показал все кадры без потерь")
|
||
if any(documents[label]["rf_tx"] != 0
|
||
for label in ("video002", "design", "synthetic", "stream", "codec", "transport",
|
||
"benchmark_manifest", "benchmark_before", "benchmark_after",
|
||
"transport004", "interleaving004", "viterbi004", "long004",
|
||
"pipeline_manifest004", "pipeline004", "keyframe004",
|
||
"seed_sweep004", "benchmark005")):
|
||
raise AssertionError("в результатах найден ненулевой RF TX")
|
||
|
||
from tools.video004_benchmark import load_fixture, run as benchmark_run
|
||
_, manifest = load_fixture()
|
||
before, after = documents["benchmark_before"], documents["benchmark_after"]
|
||
if (before["signal_sha256"] != after["signal_sha256"]
|
||
or before["signal_sha256"] != manifest["signal_sha256"]
|
||
or before["outcomes"] != after["outcomes"]):
|
||
raise AssertionError("эталонные выходы приёмника до/после изменились")
|
||
measured = benchmark_run("after", repetitions=3)
|
||
if (measured["outcomes"] != before["outcomes"]
|
||
or measured["available_path_realtime_factor"] > 0.67):
|
||
raise AssertionError("производительность или строгий приём VIDEO-004 изменились")
|
||
protected = documents["transport004"]
|
||
interleaving = documents["interleaving004"]
|
||
if (len(protected["cases"]) != 1440 or len(interleaving["cases"]) != 48
|
||
or protected["source_sha256"] != documents["codec"]["source_sha256"]
|
||
or interleaving["source_sha256"] != documents["codec"]["source_sha256"]):
|
||
raise AssertionError("неполная матрица внешнего FEC")
|
||
if any(not case["no_old_frame_replay"] for case in
|
||
protected["cases"] + interleaving["cases"]):
|
||
raise AssertionError("повторно показан старый кадр VIDEO-004")
|
||
if any(case["max_frame_age_ms"] is not None and case["max_frame_age_ms"] >= 300
|
||
for case in protected["cases"] + interleaving["cases"]):
|
||
raise AssertionError("показан просроченный кадр VIDEO-004")
|
||
selected = [case for case in protected["cases"]
|
||
if case["mode"] == "NORMAL" and case["fec_rate"] == "2/3"
|
||
and case["data_count"] == 8 and case["parity_count"] == 2
|
||
and not case["keyframe_boost"] and case["scenario"] == "random"]
|
||
if len(selected) != 12 or sum(case["displayed_frames"] for case in selected
|
||
if case["level"] == 0) != 120:
|
||
raise AssertionError("выбранный внешний FEC теряет кадры без потерь канала")
|
||
viterbi = documents["viterbi004"]
|
||
long_run = documents["long004"]
|
||
pipeline = documents["pipeline004"]
|
||
if (not viterbi["bits_equal"] or viterbi["signal_sha256"] != manifest["signal_sha256"]
|
||
or not long_run["strict_outputs_equal"] or long_run["packet_count"] != 256
|
||
or not pipeline["exact_packets_and_frames"] or pipeline["parity_recoveries"] != 4
|
||
or pipeline["numba"]["realtime_factor"] > 0.67):
|
||
raise AssertionError("длинный или полный программный тракт VIDEO-004 не прошёл")
|
||
from tools.video004_pipeline_benchmark import SIGNAL, decode
|
||
samples = np.load(SIGNAL, allow_pickle=False)
|
||
if hashlib.sha256(samples.tobytes()).hexdigest() != documents["pipeline_manifest004"]["signal_sha256"]:
|
||
raise AssertionError("контрольный сигнал внешнего FEC изменился")
|
||
started = time.perf_counter()
|
||
current = decode(samples, "numba")
|
||
elapsed = time.perf_counter() - started
|
||
if (current["packet_sha256"] != documents["pipeline_manifest004"]["expected_packet_sha256"]
|
||
or current["frame_sha256"] != documents["pipeline_manifest004"]["expected_frame_sha256"]
|
||
or elapsed / pipeline["signal_duration_s"] > 0.67):
|
||
raise AssertionError("полный доступный программный тракт изменился")
|
||
keyframe = documents["keyframe004"]
|
||
sweep = documents["seed_sweep004"]
|
||
if (len(keyframe["cases"]) != 24 or len(sweep["cases"]) != 180
|
||
or sweep["seeds"] != list(range(20260916, 20260921))):
|
||
raise AssertionError("неполная проверка опорного кадра или затравок")
|
||
base_double = [case for case in keyframe["cases"] if case["fec_rate"] == "2/3"
|
||
and case["data_count"] == 8 and case["parity_count"] == 1
|
||
and not case["keyframe_boost"] and case["scenario"] == "keyframe_double"]
|
||
boosted_double = [case for case in keyframe["cases"] if case["fec_rate"] == "2/3"
|
||
and case["data_count"] == 8 and case["parity_count"] == 1
|
||
and case["keyframe_boost"] and case["scenario"] == "keyframe_double"]
|
||
if (len(base_double) != 2 or len(boosted_double) != 2
|
||
or sum(x["displayed_frames"] for x in base_double) != 110
|
||
or sum(x["displayed_frames"] for x in boosted_double) != 120):
|
||
raise AssertionError("защита опорного кадра изменила результат")
|
||
|
||
benchmark005 = documents["benchmark005"]
|
||
saved_radio = benchmark005["radio"]
|
||
if (saved_radio["packets"] != 32
|
||
or saved_radio["strict_outcome"]["exact_packets"] != 32
|
||
or saved_radio["strict_outcome"]["unexpected_or_wrong_packets"] != 0
|
||
or saved_radio["realtime_factor"] > 0.67):
|
||
raise AssertionError("сохранённый IQ-контроль VIDEO-005 не прошёл")
|
||
cases = {case["scenario"]: case for case in benchmark005["video"]["cases"]}
|
||
if (len(cases) != 6 or cases["clean"]["video"]["unique_decoded_frames"] != 20
|
||
or cases["random_1"]["video"]["unique_decoded_frames"] != 20
|
||
or cases["keyframe_double"]["video"]["unique_decoded_frames"] != 20
|
||
or cases["corrupt_packet"]["video"]["unique_decoded_frames"] != 20
|
||
or any(not case["exact_crc_packets"] or case["video"]["wrong_decoded_frames"]
|
||
or case["video"]["old_frame_replay"] for case in cases.values())):
|
||
raise AssertionError("видеорезультаты VIDEO-005 нарушают строгий контроль")
|
||
from tools.video005_benchmark import _strict_outcomes, build_radio_fixture
|
||
from protocol.video005_receiver import receive_iq_stream
|
||
reference005, _, receiver_iq = build_radio_fixture()
|
||
if hashlib.sha256(receiver_iq.tobytes()).hexdigest() != saved_radio["signal_sha256"]:
|
||
raise AssertionError("IQ-сигнал VIDEO-005 изменился")
|
||
started = time.perf_counter()
|
||
current005 = receive_iq_stream(receiver_iq)
|
||
elapsed005 = time.perf_counter() - started
|
||
current_outcome = _strict_outcomes(current005, reference005)
|
||
current_outcome["packet_sha256_by_sequence"] = {
|
||
str(index): digest
|
||
for index, digest in current_outcome["packet_sha256_by_sequence"].items()
|
||
}
|
||
if (current_outcome != saved_radio["strict_outcome"]
|
||
or elapsed005 / saved_radio["iq_duration_s"] > 0.67):
|
||
raise AssertionError("строгий IQ-приём VIDEO-005 или его скорость изменились")
|
||
from tools.video005_video import build_fixture, run_case
|
||
fixture005 = build_fixture(20)
|
||
if (fixture005.source_sha256 != benchmark005["video"]["source_sha256"]
|
||
or len(fixture005.transmissions) != benchmark005["video"]["scheduled_phy_packets"]):
|
||
raise AssertionError("видеовход VIDEO-005 изменился")
|
||
for scenario in ("clean", "random_5"):
|
||
actual = run_case(fixture005, scenario)
|
||
saved = cases[scenario]
|
||
if (actual["crc_valid_radio_packets"] != saved["crc_valid_radio_packets"]
|
||
or actual["exact_crc_packets"] != saved["exact_crc_packets"]
|
||
or any(actual["video"][key] != saved["video"][key] for key in (
|
||
"displayed_frame_ids", "recovered_fragments", "stale_drops",
|
||
"wrong_encoded_frames", "wrong_decoded_frames", "old_frame_replay",
|
||
))):
|
||
raise AssertionError(f"повторная сборка видео изменила результат: {scenario}")
|
||
|
||
|
||
def _run_static_gates(environment: dict[str, str]) -> None:
|
||
commands = (
|
||
[sys.executable, "-B", "-m", "ruff", "check", *STATIC_PATHS],
|
||
[sys.executable, "-B", "-m", "pyright", "--project", "pyrightconfig.json"],
|
||
["git", "diff", "--check"],
|
||
)
|
||
for command in commands:
|
||
completed = subprocess.run(command, cwd=ROOT, env=environment, check=False)
|
||
if completed.returncode != 0:
|
||
raise SystemExit(f"статический шлюз завершился ошибкой: {command[0:4]}")
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--numba-packages", type=Path)
|
||
parser.add_argument("--dev-packages", type=Path)
|
||
args = parser.parse_args()
|
||
if args.numba_packages is not None:
|
||
if not args.numba_packages.is_dir():
|
||
parser.error("каталог локальных пакетов Numba отсутствует")
|
||
sys.path.insert(0, str(args.numba_packages.resolve()))
|
||
if args.dev_packages is not None and not args.dev_packages.is_dir():
|
||
parser.error("каталог dev-зависимостей отсутствует")
|
||
_check_imports_and_hardware_boundary()
|
||
documents = _load_artifacts()
|
||
_check_results(documents)
|
||
output_root = (ROOT / "data/processed/video005").resolve()
|
||
if not output_root.is_relative_to(ROOT.resolve()):
|
||
raise AssertionError("временный каталог вне worktree")
|
||
environment = os.environ.copy()
|
||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||
if args.numba_packages is not None:
|
||
environment["PYTHONPATH"] = str(args.numba_packages.resolve()) + os.pathsep + environment.get("PYTHONPATH", "")
|
||
if args.dev_packages is not None:
|
||
environment["PYTHONPATH"] = (str(args.dev_packages.resolve()) + os.pathsep
|
||
+ environment.get("PYTHONPATH", ""))
|
||
_run_static_gates(environment)
|
||
with tempfile.TemporaryDirectory(prefix="video_gate_pytest_", dir=output_root) as name:
|
||
temporary = Path(name).resolve()
|
||
if not temporary.is_relative_to(output_root):
|
||
raise AssertionError("временный каталог вне результата VIDEO-005")
|
||
command = [sys.executable, "-B", "-m", "pytest", *TESTS,
|
||
"-q", "-p", "no:cacheprovider", "--basetemp", str(temporary)]
|
||
completed = subprocess.run(command, cwd=ROOT, env=environment, check=False)
|
||
if completed.returncode != 0:
|
||
raise SystemExit(completed.returncode)
|
||
print("VIDEO-SPECIFIC GATE: PASS; IQ, видео, ruff, pyright, git diff, тесты и RF-граница")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|