Complete VIDEO-001 through VIDEO-004 software video studies

This commit is contained in:
LittleSam129
2026-09-16 18:27:35 +03:00
parent af7c05f2da
commit 95f664076a
403 changed files with 392009 additions and 0 deletions

200
tools/video004_benchmark.py Normal file
View File

@@ -0,0 +1,200 @@
"""Воспроизводимый замер доступного программного QPSK-приёмника без SDR."""
from __future__ import annotations
import argparse
import cProfile
import hashlib
import json
import math
import os
from pathlib import Path
import pstats
import sys
import time
import tracemalloc
import numpy as np
from protocol import punctured_fec
from protocol.video_stream_transport import FrameType, VideoFragment, decode_fragment, encode_fragment
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame,
)
from tools.video003_design import ROOT
RESULT_ROOT = ROOT / "data/processed/video004"
SIGNAL_PATH = RESULT_ROOT / "canonical_symbols.npy"
MANIFEST_PATH = RESULT_ROOT / "canonical_manifest.json"
SEED = 20260916
PACKETS = 32
RATE = "2/3"
SYMBOL_RATE = 500_000
TRANSIENT_SYMBOLS = 42
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def create_fixture(packet_count: int = PACKETS, signal_path: Path = SIGNAL_PATH,
manifest_path: Path = MANIFEST_PATH) -> dict:
"""Создать вход; эталонные пакеты сохраняются только для сверки после приёма."""
random_generator = np.random.default_rng(SEED)
frames = []
if not 1 <= packet_count <= 256:
raise ValueError("число пакетов вне диапазона")
for index in range(packet_count):
data = random_generator.integers(0, 256, 995, dtype=np.uint8).tobytes()
fragment = VideoFragment(0x56303033, index, 0, 1,
FrameType.KEYFRAME if index % 10 == 0 else FrameType.DELTA_FRAME,
0, data)
frames.append(build_frame(encode_fragment(fragment, 1012), "QPSK", RATE,
sequence_number=index))
symbols = np.concatenate([part for frame in frames for part in
(frame.symbols, np.zeros(TRANSIENT_SYMBOLS, dtype=np.complex128))])
body_symbols = math.ceil(punctured_fec.coded_bit_count(1024 * 8, RATE) / 2)
n0 = body_symbols / (1024 * 8) / 10 ** (9 / 10)
sigma = math.sqrt(n0 / 2)
phase = random_generator.uniform(-math.pi, math.pi) + math.pi / 2
indexes = np.arange(len(symbols), dtype=np.float64)
received = symbols * np.exp(1j * (phase + 2 * math.pi * 73 * indexes / SYMBOL_RATE))
received += sigma * (random_generator.standard_normal(len(symbols))
+ 1j * random_generator.standard_normal(len(symbols)))
leading = sigma * (random_generator.standard_normal(11)
+ 1j * random_generator.standard_normal(11))
received = np.concatenate((leading, received))
RESULT_ROOT.mkdir(parents=True, exist_ok=True)
np.save(signal_path, received, allow_pickle=False)
manifest = {
"seed": SEED, "packet_count": packet_count, "modulation": "QPSK",
"symbol_rate": SYMBOL_RATE, "fec_rate": RATE, "ebn0_db": 9,
"carrier_offset_hz": 73, "transient_symbols_per_packet": TRANSIENT_SYMBOLS,
"leading_noise_symbols": 11, "signal_duration_s": len(symbols) / SYMBOL_RATE,
"received_symbols": len(received), "dtype": str(received.dtype),
"signal_sha256": _sha(received.tobytes()),
"expected_packet_sha256": [_sha(frame.packet) for frame in frames],
"rf_tx": 0,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
return manifest
def load_fixture(signal_path: Path = SIGNAL_PATH,
manifest_path: Path = MANIFEST_PATH) -> tuple[np.ndarray, dict]:
received = np.load(signal_path, allow_pickle=False)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if (str(received.dtype) != manifest["dtype"]
or len(received) != manifest["received_symbols"]
or _sha(received.tobytes()) != manifest["signal_sha256"]):
raise ValueError("контрольный сигнал изменился")
return received, manifest
def decode_stream(received: np.ndarray, backend: str,
packet_count: int = PACKETS) -> list[dict]:
"""В приёмник не передаются границы, длины или эталонные пакеты."""
if backend == "legacy":
fec_decoder = None
elif backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
else:
raise ValueError("неизвестный декодер")
cursor = 0
outcomes = []
for _ in range(packet_count):
kwargs = {} if fec_decoder is None else {"fec_decoder": fec_decoder}
item = receive_frame(received[cursor:], "QPSK", RATE, SYMBOL_RATE, **kwargs)
if not item.crc_pass or item.packet is None or item.payload is None or item.start_symbol is None:
outcomes.append({"crc_pass": False, "start_symbol": item.start_symbol})
break
fragment = decode_fragment(item.payload, 1012)
hard_bits = (item.coded_soft >= 0).astype(np.uint8)
outcomes.append({
"crc_pass": True, "packet_sha256": _sha(item.packet),
"decoded_bits_sha256": _sha(np.unpackbits(np.frombuffer(item.packet, dtype=np.uint8)).tobytes()),
"qpsk_decisions_sha256": _sha(np.packbits(hard_bits).tobytes()),
"video_frame_id": fragment.frame_id, "video_data_sha256": _sha(fragment.data),
"start_symbol": item.start_symbol,
})
body_count = math.ceil(punctured_fec.coded_bit_count(len(item.packet) * 8, RATE) / 2)
cursor += item.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count + TRANSIENT_SYMBOLS
return outcomes
def run(phase: str, repetitions: int = 3) -> dict:
if phase not in ("before", "after") or repetitions < 1:
raise ValueError("неверный этап или число повторов")
if phase == "before" and not SIGNAL_PATH.exists():
create_fixture()
received, manifest = load_fixture()
backend = "legacy" if phase == "before" else "numba"
started = time.perf_counter()
first_outcomes = decode_stream(received, backend)
first_time = time.perf_counter() - started
times = []
for _ in range(repetitions):
started = time.perf_counter()
outcomes = decode_stream(received, backend)
times.append(time.perf_counter() - started)
if outcomes != first_outcomes:
raise AssertionError("повтор приёма изменил строгие выходы")
if len(outcomes) != PACKETS or not all(x["crc_pass"] for x in outcomes):
raise AssertionError("контрольный поток не принят полностью")
if [x["packet_sha256"] for x in outcomes] != manifest["expected_packet_sha256"]:
raise AssertionError("CRC-пакеты отличаются от эталона")
profiler = cProfile.Profile()
profiler.enable()
profiled_outcomes = decode_stream(received, backend)
profiler.disable()
if profiled_outcomes != outcomes:
raise AssertionError("профилировщик изменил строгие выходы")
stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime")
listing = [{"file": key[0], "line": key[1], "function": key[2],
"primitive_calls": value[0], "calls": value[1],
"self_s": value[2], "cumulative_s": value[3]}
for key, value in stats.stats.items()]
listing.sort(key=lambda item: item["cumulative_s"], reverse=True)
tracemalloc.start()
decode_stream(received, backend)
_, python_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
ordered = sorted(times)
median = ordered[len(ordered) // 2]
return {
"phase": phase, "backend": backend, "signal_sha256": manifest["signal_sha256"],
"signal_duration_s": manifest["signal_duration_s"],
"jit_first_run_s" if phase == "after" else "first_run_s": first_time,
"warm_run_times_s": times, "warm_median_s": median,
"available_path_realtime_factor": median / manifest["signal_duration_s"],
"profile_top": listing[:50], "profile_total_s": stats.total_tt,
"tracemalloc_python_peak_bytes": python_peak,
"tracemalloc_limit": "does not include all native NumPy/Numba allocations",
"outcomes": outcomes, "timing_recovery": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"carrier_tracking_from_raw_iq": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"rf_tx": 0,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--phase", choices=("before", "after"), required=True)
parser.add_argument("--repetitions", type=int, default=3)
parser.add_argument("--numba-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()))
os.environ["NUMBA_CACHE_DIR"] = str(RESULT_ROOT / "numba_cache")
(RESULT_ROOT / "numba_cache").mkdir(parents=True, exist_ok=True)
result = run(args.phase, args.repetitions)
output = RESULT_ROOT / f"benchmark_{args.phase}.json"
output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{args.phase}: first={result.get('jit_first_run_s', result.get('first_run_s')):.3f}s "
f"warm={result['warm_median_s']:.3f}s factor={result['available_path_realtime_factor']:.3f}")
if __name__ == "__main__":
main()