VIDEO-005: add full offline QPSK IQ video receiver
This commit is contained in:
179
tools/video005_benchmark.py
Normal file
179
tools/video005_benchmark.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""Детерминированный контроль IQ-приёмника и полного программного видео."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import cProfile
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pstats
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol.packet import parse_packet
|
||||
from protocol.video003_modem import build_frame
|
||||
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, shape_stream
|
||||
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||
from tools.video005_channel import impair, lab066_like
|
||||
from tools.video005_video import build_fixture, run_case
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT = ROOT / "data/processed/video005/benchmark.json"
|
||||
SEED = 20260917
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _strict_outcomes(result: IqStreamResult, expected: list[bytes]) -> dict:
|
||||
observed = {parse_packet(item.packet).sequence_number: _sha(item.packet)
|
||||
for item in result.packets}
|
||||
reference = {index: _sha(packet) for index, packet in enumerate(expected)}
|
||||
return {
|
||||
"crc_valid_packets": len(result.packets),
|
||||
"exact_packets": sum(observed.get(index) == digest
|
||||
for index, digest in reference.items()),
|
||||
"unexpected_or_wrong_packets": sum(index not in reference or digest != reference[index]
|
||||
for index, digest in observed.items()),
|
||||
"packet_sha256_by_sequence": observed,
|
||||
}
|
||||
|
||||
|
||||
def _profile(receiver_iq) -> dict:
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
receive_iq_stream(receiver_iq)
|
||||
profiler.disable()
|
||||
stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime")
|
||||
raw_stats = vars(stats)["stats"] # Атрибут pstats создаётся динамически.
|
||||
listing = [{"function": key[2], "file": key[0], "calls": values[1],
|
||||
"self_s": values[2], "cumulative_s": values[3]}
|
||||
for key, values in raw_stats.items()]
|
||||
listing.sort(key=lambda item: item["cumulative_s"], reverse=True)
|
||||
selected = ("oaconvolve", "search_preamble_grid", "refine_timing",
|
||||
"estimate_from_preamble", "track_qpsk_body", "qpsk_soft",
|
||||
"depuncture_soft", "decode_soft", "_trellis", "parse_packet")
|
||||
stage_functions = [item for item in listing if item["function"] in selected]
|
||||
return {"profile_total_s": vars(stats)["total_tt"],
|
||||
"top_functions": listing[:40], "selected_stages": stage_functions}
|
||||
|
||||
|
||||
def build_radio_fixture() -> tuple[list[bytes], np.ndarray, np.ndarray]:
|
||||
"""Канонический IQ-вход для 32 полных пакетов; эталон остаётся снаружи RX."""
|
||||
frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)),
|
||||
"QPSK", "2/3", sequence_number=index)
|
||||
for index in range(32)]
|
||||
clean_iq = shape_stream(frames)
|
||||
channel_iq = impair(clean_iq, cfo_hz=20, phase_degrees=15,
|
||||
timing_offset_symbols=0.1, ebn0_db=9, seed=SEED)
|
||||
return [frame.packet for frame in frames], clean_iq, channel_iq
|
||||
|
||||
|
||||
def run() -> dict:
|
||||
reference, clean_iq, channel_iq = build_radio_fixture()
|
||||
duration = len(channel_iq) / SAMPLE_RATE_HZ
|
||||
signal_sha256 = _sha(channel_iq.tobytes())
|
||||
|
||||
started = time.perf_counter()
|
||||
first = receive_iq_stream(channel_iq)
|
||||
first_time = time.perf_counter() - started
|
||||
first_outcome = _strict_outcomes(first, reference)
|
||||
warm_times = []
|
||||
warm_results = []
|
||||
for _ in range(3):
|
||||
started = time.perf_counter()
|
||||
received = receive_iq_stream(channel_iq)
|
||||
warm_times.append(time.perf_counter() - started)
|
||||
warm_results.append(received)
|
||||
if _strict_outcomes(received, reference) != first_outcome:
|
||||
raise AssertionError("вход IQ дал разные строгие пакеты на повторах")
|
||||
median_time = statistics.median(warm_times)
|
||||
profile = _profile(channel_iq)
|
||||
|
||||
awgn_frames = [build_frame(bytes(((bit + index) * 37) % 256 for bit in range(1012)),
|
||||
"QPSK", "2/3", sequence_number=index)
|
||||
for index in range(16)]
|
||||
awgn_iq = shape_stream(awgn_frames)
|
||||
awgn = []
|
||||
for ebn0_db in (0, 3, 6, 9):
|
||||
received = receive_iq_stream(impair(awgn_iq, ebn0_db=ebn0_db, seed=SEED))
|
||||
awgn.append({"ebn0_db": ebn0_db,
|
||||
**_strict_outcomes(received, [frame.packet for frame in awgn_frames])})
|
||||
|
||||
diagnostic_iq, diagnostic_parameters = lab066_like(clean_iq, seed=SEED)
|
||||
diagnostic = receive_iq_stream(diagnostic_iq)
|
||||
diagnostic_outcome = _strict_outcomes(diagnostic, reference)
|
||||
|
||||
fixture = build_fixture(20)
|
||||
video_cases = [run_case(fixture, scenario) for scenario in (
|
||||
"clean", "random_1", "random_5", "keyframe_double",
|
||||
"keyframe_triple", "corrupt_packet",
|
||||
)]
|
||||
if not all(case["exact_crc_packets"] for case in video_cases):
|
||||
raise AssertionError("после CRC обнаружен неправильный пакет")
|
||||
if not all(not case["video"]["wrong_encoded_frames"]
|
||||
and not case["video"]["wrong_decoded_frames"]
|
||||
and not case["video"]["old_frame_replay"] for case in video_cases):
|
||||
raise AssertionError("в видеотракте обнаружены испорченные или повторные кадры")
|
||||
clean_video = video_cases[0]
|
||||
return {
|
||||
"status": (
|
||||
"PARTIAL" if (clean_video["end_to_end_realtime_factor"] > 0.67
|
||||
or video_cases[2]["video"]["frame_loss_rate"] > 0)
|
||||
else "PASS"
|
||||
),
|
||||
"seed": SEED, "rf_tx": 0, "sdr_hardware": "NOT ACCESSED",
|
||||
"radio": {
|
||||
"packets": len(reference), "iq_samples": len(channel_iq),
|
||||
"iq_duration_s": duration, "signal_sha256": signal_sha256,
|
||||
"jit_first_run_s": first_time, "jit_first_outcome": first_outcome,
|
||||
"warm_times_s": warm_times, "warm_median_s": median_time,
|
||||
"realtime_factor": median_time / duration,
|
||||
"realtime_speed": duration / median_time,
|
||||
"strict_outcome": first_outcome,
|
||||
"stage_wall_s": warm_results[-1].stage_time_s,
|
||||
"cprofile": profile,
|
||||
},
|
||||
"awgn": awgn,
|
||||
"lab066_like": {"parameters": diagnostic_parameters,
|
||||
"outcome": diagnostic_outcome},
|
||||
"video": {
|
||||
"source_sha256": fixture.source_sha256,
|
||||
"original_frames": len(fixture.frames),
|
||||
"scheduled_phy_packets": len(fixture.transmissions),
|
||||
"iq_duration_s": len(fixture.iq) / SAMPLE_RATE_HZ,
|
||||
"preparation_time_s": fixture.preparation_time_s,
|
||||
"cases": video_cases,
|
||||
},
|
||||
"sample_clock_offset": "NOT MODELED IN VIDEO-005",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
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()))
|
||||
result = run()
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"RADIO IQ factor={result['radio']['realtime_factor']:.3f}; "
|
||||
f"exact={result['radio']['strict_outcome']['exact_packets']}/32")
|
||||
for case in result["video"]["cases"]:
|
||||
print(f"{case['scenario']}: CRC={case['crc_valid_radio_packets']}, "
|
||||
f"displayed={case['video']['unique_decoded_frames']}/20, "
|
||||
f"radio={case['radio_realtime_factor']:.3f}, "
|
||||
f"end-to-end={case['end_to_end_realtime_factor']:.3f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
109
tools/video005_channel.py
Normal file
109
tools/video005_channel.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Только программные нарушения IQ; эталоны сюда не передаются приёмнику."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import statistics
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
from scipy.optimize import brentq
|
||||
from scipy.signal import lfilter
|
||||
|
||||
from protocol.video003_modem import SAMPLE_RATE_HZ, samples_per_symbol
|
||||
from protocol.video005_iq_waveform import NOMINAL_CARRIER_HZ, SYMBOL_RATE
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LAB066_RESULT = ROOT / "data/processed/lab066/rf_result.json"
|
||||
|
||||
|
||||
def impair(
|
||||
iq: np.ndarray,
|
||||
*,
|
||||
timing_offset_symbols: float = 0.0,
|
||||
cfo_hz: float = 0.0,
|
||||
phase_degrees: float = 0.0,
|
||||
ebn0_db: float | None = None,
|
||||
seed: int = 20260917,
|
||||
symbol_rate: int = SYMBOL_RATE,
|
||||
) -> np.ndarray:
|
||||
"""Сдвиг времени, CFO, фаза и AWGN с фиксированной затравкой."""
|
||||
if not all(math.isfinite(value) for value in (timing_offset_symbols, cfo_hz, phase_degrees)):
|
||||
raise ValueError("параметры канала должны быть конечными")
|
||||
if ebn0_db is not None and not math.isfinite(ebn0_db):
|
||||
raise ValueError("Eb/N0 должно быть конечным")
|
||||
transmitted = np.asarray(iq, dtype=np.complex64)
|
||||
received = np.empty_like(transmitted)
|
||||
delay = timing_offset_symbols * samples_per_symbol(symbol_rate)
|
||||
# Ограничиваем рабочие массивы: полная видеозапись занимает миллионы IQ-отсчётов.
|
||||
random_generator = np.random.default_rng(seed)
|
||||
sigma = (math.sqrt(1 / (2 * (2 / 3) * 10 ** (ebn0_db / 10)) / 2)
|
||||
if ebn0_db is not None else 0.0)
|
||||
for start in range(0, transmitted.size, 1_000_000):
|
||||
stop = min(start + 1_000_000, transmitted.size)
|
||||
indexes = np.arange(start, stop, dtype=np.float64)
|
||||
if delay:
|
||||
positions = indexes - delay
|
||||
lower = np.floor(positions).astype(np.int64)
|
||||
valid = (lower >= 0) & (lower + 1 < transmitted.size)
|
||||
part = np.zeros(stop - start, dtype=np.complex64)
|
||||
fraction = positions[valid] - lower[valid]
|
||||
part[valid] = (transmitted[lower[valid]] * (1 - fraction)
|
||||
+ transmitted[lower[valid] + 1] * fraction)
|
||||
else:
|
||||
part = transmitted[start:stop].copy()
|
||||
if cfo_hz or phase_degrees:
|
||||
phase = math.radians(phase_degrees) + 2 * math.pi * cfo_hz * indexes / SAMPLE_RATE_HZ
|
||||
part *= np.exp(1j * phase).astype(np.complex64)
|
||||
if ebn0_db is not None:
|
||||
part += (sigma * (random_generator.standard_normal(stop - start)
|
||||
+ 1j * random_generator.standard_normal(stop - start))
|
||||
).astype(np.complex64)
|
||||
received[start:stop] = part
|
||||
return received
|
||||
|
||||
|
||||
def lab066_like(iq: np.ndarray, *, seed: int = 20260917) -> tuple[np.ndarray, dict]:
|
||||
"""Диагностический суррогат по двум измеренным сводным характеристикам.
|
||||
|
||||
Частотная рябь и добавочный остаток не воспроизводят точную OTA-передачу.
|
||||
"""
|
||||
report = json.loads(LAB066_RESULT.read_text(encoding="utf-8"))
|
||||
rows = report["physical_diagnostics"]["rows"]
|
||||
ripple_db = statistics.median(row["band_gain_nonuniformity_p95_p05_db"] for row in rows)
|
||||
useful_to_residual_db = statistics.mean(row["matched_residual_db"] for row in rows)
|
||||
frequencies = np.linspace(NOMINAL_CARRIER_HZ - 337_500,
|
||||
NOMINAL_CARRIER_HZ + 337_500, 2048)
|
||||
delay_samples = 8
|
||||
|
||||
def modeled_ripple(alpha: float) -> float:
|
||||
response = np.abs(1 + alpha * np.exp(-2j * np.pi * frequencies
|
||||
* delay_samples / SAMPLE_RATE_HZ))
|
||||
return 20 * math.log10(float(np.percentile(response, 95)
|
||||
/ np.percentile(response, 5)))
|
||||
|
||||
alpha = cast(float, brentq(lambda value: modeled_ripple(value) - ripple_db,
|
||||
0.0, 0.8, full_output=False))
|
||||
coefficients = np.zeros(delay_samples + 1)
|
||||
coefficients[0], coefficients[-1] = 1.0, alpha
|
||||
filtered = cast(np.ndarray, lfilter(coefficients, [1.0],
|
||||
np.asarray(iq, dtype=np.complex64)))
|
||||
residual_ratio = 10 ** (useful_to_residual_db / 10)
|
||||
sigma = math.sqrt(1 / (2 * residual_ratio))
|
||||
random_generator = np.random.default_rng(seed)
|
||||
result = filtered + sigma * (
|
||||
random_generator.standard_normal(filtered.size)
|
||||
+ 1j * random_generator.standard_normal(filtered.size)
|
||||
)
|
||||
return result.astype(np.complex64), {
|
||||
"label": "LAB066-LIKE DIAGNOSTIC CHANNEL MODEL",
|
||||
"measured_band_p95_p05_db": ripple_db,
|
||||
"modeled_band_p95_p05_db": modeled_ripple(alpha),
|
||||
"measured_matched_residual_db": useful_to_residual_db,
|
||||
"filter_delay_samples": delay_samples,
|
||||
"filter_second_tap": alpha,
|
||||
"limitation": "FIR ripple and white residual surrogate do not reproduce OTA propagation",
|
||||
}
|
||||
354
tools/video005_video.py
Normal file
354
tools/video005_video.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""VIDEO-005: H.264 через непрерывный IQ, строгий PHY и внешний FEC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from fractions import Fraction
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
|
||||
import av
|
||||
from av.error import FFmpegError
|
||||
import numpy as np
|
||||
|
||||
from protocol import punctured_fec
|
||||
from protocol.packet import parse_packet
|
||||
from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame
|
||||
from protocol.video004_erasure import VideoErasureCollector, decode_source, parse_symbol
|
||||
from protocol.video005_iq_waveform import SAMPLE_RATE_HZ, rrc_taps, shape_packet
|
||||
from protocol.video005_receiver import IqStreamResult, receive_iq_stream
|
||||
from protocol.video_stream_transport import FrameReassembler, FrameType, VideoFrame
|
||||
from tools.video003_codec import SOURCE, encode_case
|
||||
from tools.video004_transport import _wire_schedule
|
||||
from tools.video_compare import source_frames
|
||||
from tools.video005_channel import impair
|
||||
|
||||
|
||||
STREAM_ID = 0x56303535
|
||||
FPS = 10
|
||||
SYMBOL_RATE = 500_000
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Transmission:
|
||||
index: int
|
||||
start_sample: int
|
||||
end_sample: int
|
||||
block_id: int
|
||||
source_index: int | None
|
||||
source_frame_id: int | None
|
||||
source_is_keyframe: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoFixture:
|
||||
iq: np.ndarray
|
||||
frames: tuple[VideoFrame, ...]
|
||||
transmissions: tuple[Transmission, ...]
|
||||
phy_packet_sha256: tuple[str, ...]
|
||||
clean_pixel_sha256: dict[int, str]
|
||||
source_sha256: str
|
||||
encoder_settings: dict
|
||||
preparation_time_s: dict[str, float]
|
||||
|
||||
|
||||
def _clean_pixels(packets: list[bytes]) -> dict[int, str]:
|
||||
decoder = av.CodecContext.create("h264", "r")
|
||||
decoder.open()
|
||||
hashes = {}
|
||||
for index, wire in enumerate(packets):
|
||||
packet = av.Packet(wire)
|
||||
packet.pts = packet.dts = index
|
||||
packet.time_base = Fraction(1, FPS)
|
||||
decoded = decoder.decode(packet)
|
||||
if len(decoded) != 1 or decoded[0].pts != index:
|
||||
raise AssertionError("исходный H.264 не декодировался точно")
|
||||
hashes[index] = hashlib.sha256(decoded[0].to_ndarray(format="rgb24").tobytes()).hexdigest()
|
||||
return hashes
|
||||
|
||||
|
||||
def build_fixture(frame_count: int = 20,
|
||||
pixels_override: list[np.ndarray] | None = None) -> VideoFixture:
|
||||
"""Кодирует исходный клип и передаёт видео по расписанию без oracle для RX."""
|
||||
if not 1 <= frame_count <= 20:
|
||||
raise ValueError("контроль VIDEO-005 ограничен первыми 20 кадрами")
|
||||
started = time.perf_counter()
|
||||
pixels = (source_frames(SOURCE, 640, 360, FPS, 0, frame_count / FPS)
|
||||
if pixels_override is None else pixels_override)
|
||||
if len(pixels) != frame_count or any(item.shape != (360, 640, 3) for item in pixels):
|
||||
raise ValueError("ожидаются кадры RGB 640x360 выбранной длины")
|
||||
codec, encoded = encode_case(pixels, FPS, "SHORT_GOP", 400_000)
|
||||
if len(encoded) != frame_count:
|
||||
raise AssertionError("кодек вернул неверное число кадров")
|
||||
encode_time = time.perf_counter() - started
|
||||
started = time.perf_counter()
|
||||
clean_hashes = _clean_pixels(encoded)
|
||||
reference_decode_time = time.perf_counter() - started
|
||||
frames = tuple(VideoFrame(
|
||||
STREAM_ID, index,
|
||||
FrameType.KEYFRAME if index in codec["keyframe_positions"] else FrameType.DELTA_FRAME,
|
||||
index * 100, wire,
|
||||
) for index, wire in enumerate(encoded))
|
||||
|
||||
started = time.perf_counter()
|
||||
schedule, _ = _wire_schedule(list(frames), 8, 1, True, 1)
|
||||
phy_frames = [build_frame(wire, "QPSK", "2/3", sequence_number=index)
|
||||
for index, (wire, _, _, _) in enumerate(schedule)]
|
||||
outer_phy_time = time.perf_counter() - started
|
||||
|
||||
started = time.perf_counter()
|
||||
chunks: list[np.ndarray] = []
|
||||
intervals: list[Transmission] = []
|
||||
position = 0
|
||||
for index, ((wire, ready_ms, block_id, is_parity), phy) in enumerate(
|
||||
zip(schedule, phy_frames, strict=True)
|
||||
):
|
||||
ready_sample = math.ceil(ready_ms * SAMPLE_RATE_HZ / 1000)
|
||||
if ready_sample > position:
|
||||
chunks.append(np.zeros(ready_sample - position, dtype=np.complex64))
|
||||
position = ready_sample
|
||||
waveform = shape_packet(phy)
|
||||
start_sample = position
|
||||
chunks.append(waveform)
|
||||
position += waveform.size
|
||||
symbol = parse_symbol(wire)
|
||||
source = (decode_source(symbol.data, symbol.stream_id, symbol.index, False)
|
||||
if not is_parity else None)
|
||||
intervals.append(Transmission(
|
||||
index, start_sample, position, block_id,
|
||||
symbol.index if source is not None else None,
|
||||
source.fragment.frame_id if source is not None else None,
|
||||
bool(source is not None and source.fragment.frame_type == FrameType.KEYFRAME),
|
||||
))
|
||||
iq = np.concatenate(chunks)
|
||||
tx_rrc_time = time.perf_counter() - started
|
||||
source_sha256 = (hashlib.sha256(SOURCE.read_bytes()).hexdigest()
|
||||
if pixels_override is None else hashlib.sha256(
|
||||
b"".join(item.tobytes() for item in pixels)
|
||||
).hexdigest())
|
||||
return VideoFixture(
|
||||
iq, frames, tuple(intervals),
|
||||
tuple(hashlib.sha256(frame.packet).hexdigest() for frame in phy_frames),
|
||||
clean_hashes, source_sha256,
|
||||
codec["settings"],
|
||||
{"source_and_h264_encode": encode_time, "reference_h264_diagnostic": reference_decode_time,
|
||||
"outer_fec_and_phy": outer_phy_time, "tx_rrc_and_iq": tx_rrc_time},
|
||||
)
|
||||
|
||||
|
||||
def _loss_indices(fixture: VideoFixture, scenario: str, seed: int) -> tuple[set[int], int | None]:
|
||||
random_generator = random.Random(seed)
|
||||
if scenario == "clean":
|
||||
return set(), None
|
||||
if scenario in ("random_1", "random_5"):
|
||||
probability = int(scenario.split("_")[1]) / 100
|
||||
return {item.index for item in fixture.transmissions
|
||||
if random_generator.random() < probability}, None
|
||||
if scenario in ("keyframe_double", "keyframe_triple"):
|
||||
count = 2 if scenario == "keyframe_double" else 3
|
||||
by_block: dict[int, list[Transmission]] = {}
|
||||
for item in fixture.transmissions:
|
||||
if item.source_is_keyframe and item.source_frame_id is not None \
|
||||
and item.source_frame_id >= 5:
|
||||
by_block.setdefault(item.block_id, []).append(item)
|
||||
selected = next((items for items in by_block.values() if len(items) >= count), None)
|
||||
if selected is None:
|
||||
raise ValueError("не найден блок опорного кадра для выбранных потерь")
|
||||
return {item.index for item in selected[:count]}, selected[0].source_frame_id
|
||||
if scenario == "corrupt_packet":
|
||||
selected = next(item for item in fixture.transmissions
|
||||
if item.source_frame_id is not None and item.source_frame_id >= 2)
|
||||
return {selected.index}, selected.source_frame_id
|
||||
raise ValueError("неизвестный сценарий стираний")
|
||||
|
||||
|
||||
def _packet_end_ms(packet_start_sample: float, full_packet_bytes: int) -> int:
|
||||
body_symbols = math.ceil(punctured_fec.coded_bit_count(full_packet_bytes * 8, "2/3") / 2)
|
||||
symbols = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_symbols
|
||||
# Начало в согласованном фильтре сдвинуто на длину двух RRC-фильтров минус один.
|
||||
sample = packet_start_sample - (len(rrc_taps()) - 1) + symbols * 16
|
||||
return math.ceil(sample * 1000 / SAMPLE_RATE_HZ)
|
||||
|
||||
|
||||
def assemble_video(fixture: VideoFixture, radio: IqStreamResult,
|
||||
lost_keyframe_id: int | None = None) -> dict:
|
||||
"""Подаёт только CRC-valid payload в FEC, сборщик и настоящий H.264-декодер."""
|
||||
collector = VideoErasureCollector(STREAM_ID)
|
||||
reassembler = FrameReassembler(STREAM_ID, 1012, max_age_ms=300)
|
||||
decoder = av.CodecContext.create("h264", "r")
|
||||
decoder.open()
|
||||
displayed: list[tuple[int, int, int]] = []
|
||||
completed_ids: list[int] = []
|
||||
completed_data: list[tuple[int, bytes]] = []
|
||||
decoded_frames: list[tuple[int, av.VideoFrame]] = []
|
||||
decoder_errors = 0
|
||||
erasure_time = 0.0
|
||||
transport_time = 0.0
|
||||
h264_time = 0.0
|
||||
synchronized = False
|
||||
last_displayed_id: int | None = None
|
||||
seen_ids: set[int] = set()
|
||||
for packet in radio.packets:
|
||||
now_ms = _packet_end_ms(packet.start_sample, len(packet.packet))
|
||||
started = time.perf_counter()
|
||||
deliveries = collector.offer(packet.payload, now_ms)
|
||||
erasure_time += time.perf_counter() - started
|
||||
for delivery in deliveries:
|
||||
age = now_ms - delivery.capture_ms
|
||||
if not 0 <= age < 300:
|
||||
raise AssertionError("видеофрагмент вышел за срок")
|
||||
started = time.perf_counter()
|
||||
complete = reassembler.offer_fragment(replace(delivery.fragment, age_ms=age), now_ms)
|
||||
transport_time += time.perf_counter() - started
|
||||
if complete is None:
|
||||
continue
|
||||
completed_ids.append(complete.frame_id)
|
||||
completed_data.append((complete.frame_id, complete.data))
|
||||
if complete.frame_type == FrameType.KEYFRAME:
|
||||
decoder = av.CodecContext.create("h264", "r")
|
||||
decoder.open()
|
||||
synchronized = True
|
||||
elif last_displayed_id is None or complete.frame_id != last_displayed_id + 1:
|
||||
synchronized = False
|
||||
if not synchronized:
|
||||
continue
|
||||
started = time.perf_counter()
|
||||
encoded = av.Packet(complete.data)
|
||||
encoded.pts = encoded.dts = complete.frame_id
|
||||
encoded.time_base = Fraction(1, FPS)
|
||||
try:
|
||||
decoded = decoder.decode(encoded)
|
||||
except FFmpegError:
|
||||
h264_time += time.perf_counter() - started
|
||||
decoder_errors += 1
|
||||
synchronized = False
|
||||
continue
|
||||
if len(decoded) != 1 or decoded[0].pts != complete.frame_id:
|
||||
h264_time += time.perf_counter() - started
|
||||
decoder_errors += 1
|
||||
synchronized = False
|
||||
continue
|
||||
h264_time += time.perf_counter() - started
|
||||
decoded_frames.append((complete.frame_id, decoded[0]))
|
||||
if complete.frame_id in seen_ids:
|
||||
raise AssertionError("старый кадр повторно показан")
|
||||
seen_ids.add(complete.frame_id)
|
||||
displayed.append((complete.frame_id, now_ms, now_ms - complete.capture_ms))
|
||||
last_displayed_id = complete.frame_id
|
||||
collector.finish()
|
||||
started = time.perf_counter()
|
||||
wrong_encoded_frames = sum(
|
||||
not 0 <= frame_id < len(fixture.frames)
|
||||
or data != fixture.frames[frame_id].data
|
||||
for frame_id, data in completed_data
|
||||
)
|
||||
wrong_decoded_frames = sum(
|
||||
hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest()
|
||||
!= fixture.clean_pixel_sha256.get(frame_id)
|
||||
for frame_id, frame in decoded_frames
|
||||
)
|
||||
diagnostic_time = time.perf_counter() - started
|
||||
ages = [age for _, _, age in displayed]
|
||||
recovery_ms = None
|
||||
if lost_keyframe_id is not None:
|
||||
if lost_keyframe_id in seen_ids:
|
||||
recovery_ms = 0
|
||||
else:
|
||||
later = next((arrival - fixture.frames[lost_keyframe_id].capture_ms
|
||||
for frame_id, arrival, _ in displayed
|
||||
if frame_id > lost_keyframe_id
|
||||
and fixture.frames[frame_id].frame_type == FrameType.KEYFRAME), None)
|
||||
recovery_ms = later
|
||||
return {
|
||||
"input_frames": len(fixture.frames),
|
||||
"unique_decoded_frames": len(displayed),
|
||||
"displayed_frame_ids": [frame_id for frame_id, _, _ in displayed],
|
||||
"displayed_fps": len(displayed) * FPS / len(fixture.frames),
|
||||
"frame_loss_rate": 1 - len(displayed) / len(fixture.frames),
|
||||
"p50_frame_age_ms": float(np.percentile(ages, 50)) if ages else None,
|
||||
"p95_frame_age_ms": float(np.percentile(ages, 95)) if ages else None,
|
||||
"max_frame_age_ms": max(ages) if ages else None,
|
||||
"stale_drops": collector.stale_rejections + reassembler.expired_frames,
|
||||
"recovered_fragments": collector.parity_recoveries,
|
||||
"unrecoverable_blocks": collector.unrecoverable_blocks,
|
||||
"completed_frame_ids": completed_ids,
|
||||
"wrong_encoded_frames": wrong_encoded_frames,
|
||||
"wrong_decoded_frames": wrong_decoded_frames,
|
||||
"h264_decoder_errors": decoder_errors,
|
||||
"keyframe_recovery_ms": recovery_ms,
|
||||
"old_frame_replay": displayed != sorted(displayed, key=lambda item: item[0]),
|
||||
"erasure_fec_time_s": erasure_time,
|
||||
"video_transport_time_s": transport_time,
|
||||
"h264_decode_time_s": h264_time,
|
||||
"diagnostic_verification_time_s": diagnostic_time,
|
||||
}
|
||||
|
||||
|
||||
def run_case(
|
||||
fixture: VideoFixture,
|
||||
scenario: str = "clean",
|
||||
*,
|
||||
seed: int = 20260917,
|
||||
ebn0_db: float = 9.0,
|
||||
decoder_backend: str = "numba",
|
||||
) -> dict:
|
||||
"""Потери создаются в IQ; приёмник получает только повреждённый поток."""
|
||||
selected, lost_keyframe_id = _loss_indices(fixture, scenario, seed)
|
||||
started = time.perf_counter()
|
||||
iq = impair(fixture.iq, cfo_hz=20, phase_degrees=15,
|
||||
ebn0_db=ebn0_db, seed=seed)
|
||||
if selected:
|
||||
random_generator = np.random.default_rng(seed + 1)
|
||||
for index in selected:
|
||||
item = fixture.transmissions[index]
|
||||
if scenario == "corrupt_packet":
|
||||
sigma = 2.0
|
||||
count = item.end_sample - item.start_sample
|
||||
iq[item.start_sample:item.end_sample] = sigma * (
|
||||
random_generator.standard_normal(count)
|
||||
+ 1j * random_generator.standard_normal(count)
|
||||
)
|
||||
else:
|
||||
iq[item.start_sample:item.end_sample] = 0
|
||||
channel_time = time.perf_counter() - started
|
||||
started = time.perf_counter()
|
||||
radio = receive_iq_stream(iq, decoder_backend=decoder_backend)
|
||||
radio_time = time.perf_counter() - started
|
||||
started = time.perf_counter()
|
||||
video = assemble_video(fixture, radio, lost_keyframe_id)
|
||||
video_time = time.perf_counter() - started
|
||||
video_pipeline_time = video_time - video["diagnostic_verification_time_s"]
|
||||
sequence_numbers = [parse_packet(packet.packet).sequence_number for packet in radio.packets]
|
||||
# Сравнение с эталоном только после CRC и видео-декодирования.
|
||||
exact_packets = len(set(sequence_numbers)) == len(sequence_numbers) and all(
|
||||
number < len(fixture.phy_packet_sha256)
|
||||
for number in sequence_numbers
|
||||
) and all(
|
||||
hashlib.sha256(packet.packet).hexdigest() == fixture.phy_packet_sha256[number]
|
||||
for packet, number in zip(radio.packets, sequence_numbers, strict=True)
|
||||
)
|
||||
return {
|
||||
"scenario": scenario, "seed": seed, "ebn0_db": ebn0_db,
|
||||
"iq_duration_s": len(iq) / SAMPLE_RATE_HZ,
|
||||
"scheduled_phy_packets": len(fixture.transmissions),
|
||||
"iq_erased_or_corrupted_packets": len(selected),
|
||||
"crc_valid_radio_packets": len(radio.packets),
|
||||
"radio_candidates": radio.candidate_count,
|
||||
"exact_crc_packets": exact_packets,
|
||||
"channel_time_s": channel_time, "radio_time_s": radio_time,
|
||||
"video_transport_and_decode_time_s": video_pipeline_time,
|
||||
"diagnostic_verification_time_s": video["diagnostic_verification_time_s"],
|
||||
"radio_realtime_factor": radio_time / (len(iq) / SAMPLE_RATE_HZ),
|
||||
"iq_to_decoded_video_realtime_factor": (
|
||||
radio_time + video_pipeline_time
|
||||
) / (len(iq) / SAMPLE_RATE_HZ),
|
||||
"end_to_end_realtime_factor": (
|
||||
sum(duration for stage, duration in fixture.preparation_time_s.items()
|
||||
if stage != "reference_h264_diagnostic")
|
||||
+ channel_time + radio_time + video_pipeline_time
|
||||
) / (len(iq) / SAMPLE_RATE_HZ),
|
||||
"radio_stage_time_s": radio.stage_time_s,
|
||||
"video": video,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""VIDEO-001/002/003: проверки без данных аппаратных лабораторий и без RF."""
|
||||
"""VIDEO-001–005: проверка программного видеотракта без аппаратных лабораторий и RF."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -34,6 +34,9 @@ MODULES = (
|
||||
"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",
|
||||
@@ -45,6 +48,18 @@ TESTS = (
|
||||
"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",
|
||||
@@ -66,6 +81,7 @@ ARTIFACTS = {
|
||||
"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"}
|
||||
|
||||
@@ -73,6 +89,8 @@ FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhac
|
||||
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}")
|
||||
@@ -156,9 +174,9 @@ def _check_results(documents: dict[str, dict]) -> None:
|
||||
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:
|
||||
if case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]:
|
||||
raise AssertionError("P95 возраста больше максимума")
|
||||
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"
|
||||
@@ -171,7 +189,7 @@ def _check_results(documents: dict[str, dict]) -> None:
|
||||
"benchmark_manifest", "benchmark_before", "benchmark_after",
|
||||
"transport004", "interleaving004", "viterbi004", "long004",
|
||||
"pipeline_manifest004", "pipeline004", "keyframe004",
|
||||
"seed_sweep004")):
|
||||
"seed_sweep004", "benchmark005")):
|
||||
raise AssertionError("в результатах найден ненулевой RF TX")
|
||||
|
||||
from tools.video004_benchmark import load_fixture, run as benchmark_run
|
||||
@@ -239,35 +257,101 @@ def _check_results(documents: dict[str, dict]) -> None:
|
||||
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/video003").resolve()
|
||||
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-003")
|
||||
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; результаты, импорты, аппаратная граница и тесты")
|
||||
print("VIDEO-SPECIFIC GATE: PASS; IQ, видео, ruff, pyright, git diff, тесты и RF-граница")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user