Files
SDR-Rover/protocol/video004_erasure.py

242 lines
12 KiB
Python

"""Систематическая защита видеофрагментов от потери целых PHY-пакетов.
Вызывающий код передаёт сюда только payload после строгого PHY/FEC/CRC.
Арифметика GF(256) повторно используется из packet_erasure_fec; упаковка
Lab028 не используется. Каждый внешний символ занимает полный PHY payload.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from collections import deque
import struct
from typing import Sequence
from protocol.packet_erasure_fec import (
InsufficientSymbolsError, _linear_combine, encode_parity_symbols,
matrix_inverse, systematic_generator_matrix,
)
from protocol.video003_compact_header import (
HEADER_SIZE as VIDEO_HEADER_SIZE, decode as decode_video, encode as encode_video,
)
from protocol.video_stream_transport import VideoFragment
MAGIC = 0xFEC4
VERSION = 1
HEADER_FORMAT = "!HBBIIBBBB"
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 16
SOURCE_PREFIX_FORMAT = "!HI"
SOURCE_PREFIX_SIZE = struct.calcsize(SOURCE_PREFIX_FORMAT) # length + capture_ms = 6
MAX_PHY_PAYLOAD = 1012
SYMBOL_SIZE = MAX_PHY_PAYLOAD - HEADER_SIZE # 996
MAX_VIDEO_BYTES = SYMBOL_SIZE - SOURCE_PREFIX_SIZE - VIDEO_HEADER_SIZE # 977
MAX_AGE_MS = 300
class VideoErasureError(ValueError):
"""Неверный внешний символ или противоречивый блок."""
@dataclass(frozen=True)
class Symbol:
stream_id: int
block_id: int
index: int
source_count: int
parity_count: int
is_parity: bool
data: bytes
@dataclass(frozen=True)
class Delivery:
index: int
fragment: VideoFragment
capture_ms: int
recovered: bool
raw_video_wire: bytes
def _header(stream_id: int, block_id: int, index: int,
source_count: int, parity_count: int) -> bytes:
if not (0 <= stream_id <= 0xFFFFFFFF and 0 <= block_id <= 0xFFFFFFFF
and 1 <= source_count <= 255 and 1 <= parity_count <= 255 - source_count
and 0 <= index < source_count + parity_count):
raise VideoErasureError("поля внешнего FEC-заголовка вне диапазона")
return struct.pack(HEADER_FORMAT, MAGIC, VERSION,
int(index >= source_count), stream_id, block_id,
index, source_count, parity_count, 0)
def parse_symbol(wire: bytes) -> Symbol:
"""Разобрать payload только после CRC-проверки внешнего радиопакета."""
if not isinstance(wire, bytes) or len(wire) != MAX_PHY_PAYLOAD:
raise VideoErasureError("длина внешнего символа должна быть 1012 байт")
magic, version, flags, stream_id, block_id, index, source_count, parity_count, reserved = (
struct.unpack(HEADER_FORMAT, wire[:HEADER_SIZE]))
if (magic != MAGIC or version != VERSION or flags not in (0, 1) or reserved != 0
or not 1 <= source_count <= 255
or not 1 <= parity_count <= 255 - source_count
or index >= source_count + parity_count
or bool(flags) != (index >= source_count)):
raise VideoErasureError("неверные метаданные внешнего символа")
return Symbol(stream_id, block_id, index, source_count, parity_count,
bool(flags), wire[HEADER_SIZE:])
def _source_data(fragment: VideoFragment, capture_ms: int) -> bytes:
if not 0 <= capture_ms <= 0xFFFFFFFF or len(fragment.data) > MAX_VIDEO_BYTES:
raise VideoErasureError("время или размер исходного видеофрагмента вне диапазона")
video_wire = encode_video(fragment, SYMBOL_SIZE - SOURCE_PREFIX_SIZE)
source = struct.pack(SOURCE_PREFIX_FORMAT, len(video_wire), capture_ms) + video_wire
return source + bytes(SYMBOL_SIZE - len(source))
def encode_block(fragments: Sequence[tuple[VideoFragment, int]],
block_id: int, parity_count: int) -> tuple[bytes, ...]:
"""Отправить K источников первыми, затем R проверочных символов."""
if not fragments:
raise VideoErasureError("пустой блок")
stream_id = fragments[0][0].stream_id
if any(fragment.stream_id != stream_id for fragment, _ in fragments):
raise VideoErasureError("в одном блоке разные потоки")
source_count = len(fragments)
sources = tuple(_source_data(fragment, capture_ms)
for fragment, capture_ms in fragments)
parity = encode_parity_symbols(sources, parity_count)
all_data = sources + parity
return tuple(_header(stream_id, block_id, index, source_count, parity_count) + data
for index, data in enumerate(all_data))
def decode_source(data: bytes, stream_id: int, index: int,
recovered: bool) -> Delivery:
if len(data) != SYMBOL_SIZE:
raise VideoErasureError("неверная длина исходного символа")
length, capture_ms = struct.unpack(SOURCE_PREFIX_FORMAT, data[:SOURCE_PREFIX_SIZE])
if not VIDEO_HEADER_SIZE < length <= SYMBOL_SIZE - SOURCE_PREFIX_SIZE:
raise VideoErasureError("длина восстановленного видеофрагмента вне диапазона")
end = SOURCE_PREFIX_SIZE + length
if any(data[end:]):
raise VideoErasureError("ненулевое заполнение исходного символа")
video_wire = data[SOURCE_PREFIX_SIZE:end]
fragment = decode_video(video_wire, SYMBOL_SIZE - SOURCE_PREFIX_SIZE)
if fragment.stream_id != stream_id:
raise VideoErasureError("поток в FEC и видео заголовках не совпал")
return Delivery(index, fragment, capture_ms, recovered, video_wire)
def recover_block(wires: Sequence[bytes]) -> tuple[Delivery, ...]:
"""Восстановить все K исходных символов при наличии любых K из K+R."""
if not wires:
raise InsufficientSymbolsError("нет символов блока")
symbols: dict[int, Symbol] = {}
first = parse_symbol(wires[0])
metadata = (first.stream_id, first.block_id, first.source_count, first.parity_count)
for wire in wires:
symbol = parse_symbol(wire)
if (symbol.stream_id, symbol.block_id, symbol.source_count,
symbol.parity_count) != metadata:
raise VideoErasureError("в одном блоке разные метаданные")
old = symbols.get(symbol.index)
if old is not None and old != symbol:
raise VideoErasureError("противоречивый повтор внешнего символа")
symbols[symbol.index] = symbol
k = first.source_count
if len(symbols) < k:
raise InsufficientSymbolsError(f"получено {len(symbols)} из необходимых {k}")
missing = [index for index in range(k) if index not in symbols]
if not missing:
return tuple(decode_source(symbols[index].data, first.stream_id, index, False)
for index in range(k))
selected = tuple(sorted(symbols)[:k])
generator = systematic_generator_matrix(k, first.parity_count)
inverse = matrix_inverse(tuple(generator[index] for index in selected))
received = tuple(symbols[index].data for index in selected)
return tuple(decode_source(symbols[index].data if index in symbols else
_linear_combine(inverse[index], received, SYMBOL_SIZE),
first.stream_id, index, index in missing)
for index in range(k))
@dataclass
class _PendingBlock:
source_count: int
parity_count: int
wires: dict[int, bytes] = field(default_factory=dict)
delivered: set[int] = field(default_factory=set)
class VideoErasureCollector:
"""Сразу выдаёт принятые источники; поздно восстановленные отбрасывает."""
def __init__(self, stream_id: int, max_age_ms: int = MAX_AGE_MS,
max_pending_blocks: int = 8) -> None:
if not 0 <= stream_id <= 0xFFFFFFFF or max_age_ms <= 0 or max_pending_blocks < 1:
raise ValueError("неверный поток или срок")
self.stream_id = stream_id
self.max_age_ms = max_age_ms
self.max_pending_blocks = max_pending_blocks
self.blocks: dict[int, _PendingBlock] = {}
self.closed: deque[int] = deque(maxlen=1024)
self.parity_recoveries = 0
self.stale_rejections = 0
self.unrecoverable_blocks = 0
def offer(self, wire: bytes, now_ms: int) -> tuple[Delivery, ...]:
if not isinstance(now_ms, int) or now_ms < 0:
raise VideoErasureError("неверное время приёма")
symbol = parse_symbol(wire)
if symbol.stream_id != self.stream_id:
raise VideoErasureError("чужой поток")
direct_item = None
if not symbol.is_parity:
direct_item = decode_source(symbol.data, symbol.stream_id, symbol.index, False)
if now_ms < direct_item.capture_ms:
raise VideoErasureError("фрагмент датирован будущим временем")
if symbol.block_id in self.closed:
return ()
if symbol.block_id not in self.blocks and len(self.blocks) >= self.max_pending_blocks:
oldest_id = next(iter(self.blocks))
oldest = self.blocks.pop(oldest_id)
self.unrecoverable_blocks += len(oldest.wires) < oldest.source_count
self.closed.append(oldest_id)
block = self.blocks.setdefault(symbol.block_id,
_PendingBlock(symbol.source_count, symbol.parity_count))
if (block.source_count, block.parity_count) != (symbol.source_count, symbol.parity_count):
raise VideoErasureError("противоречивые параметры блока")
old = block.wires.get(symbol.index)
if old is not None and old != wire:
raise VideoErasureError("противоречивый повтор символа")
block.wires[symbol.index] = wire
output = []
if not symbol.is_parity and symbol.index not in block.delivered:
item = direct_item
block.delivered.add(symbol.index)
if now_ms - item.capture_ms < self.max_age_ms:
output.append(item)
else:
self.stale_rejections += 1
if len(block.wires) >= block.source_count and len(block.delivered) < block.source_count:
for item in recover_block(tuple(block.wires.values())):
if item.index in block.delivered:
continue
if now_ms < item.capture_ms:
raise VideoErasureError("восстановленный фрагмент датирован будущим временем")
block.delivered.add(item.index)
if now_ms - item.capture_ms >= self.max_age_ms:
self.stale_rejections += 1
continue
self.parity_recoveries += 1
output.append(item)
if len(block.delivered) == block.source_count:
del self.blocks[symbol.block_id]
self.closed.append(symbol.block_id)
return tuple(output)
def finish(self) -> None:
self.unrecoverable_blocks += sum(
len(block.wires) < block.source_count for block in self.blocks.values())
self.blocks.clear()