355 lines
16 KiB
Python
355 lines
16 KiB
Python
"""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,
|
||
}
|