Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
241
protocol/video004_erasure.py
Normal file
241
protocol/video004_erasure.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Систематическая защита видеофрагментов от потери целых 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()
|
||||
Reference in New Issue
Block a user