Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
81
protocol/punctured_fec.py
Normal file
81
protocol/punctured_fec.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Программные варианты R=2/3 и 3/4 на базе штатного кода R=1/2.
|
||||
|
||||
Прокалывание удаляет известные позиции кодового слова. При декодировании
|
||||
эти позиции имеют нулевую мягкую метрику (стирание), после чего работает
|
||||
штатный терминированный декодер Витерби.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
import numpy as np
|
||||
|
||||
from protocol import convolutional_fec
|
||||
|
||||
|
||||
PUNCTURE_PATTERNS: dict[str, tuple[int, ...]] = {
|
||||
"1/2": (1, 1),
|
||||
"2/3": (1, 1, 1, 0),
|
||||
"3/4": (1, 1, 1, 0, 0, 1),
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def _mask(mother_bit_count: int, rate: str) -> np.ndarray:
|
||||
if rate not in PUNCTURE_PATTERNS:
|
||||
raise ValueError("неизвестная скорость кода")
|
||||
if mother_bit_count < 0:
|
||||
raise ValueError("отрицательная длина кодового слова")
|
||||
pattern = np.asarray(PUNCTURE_PATTERNS[rate], dtype=bool)
|
||||
mask = np.resize(pattern, mother_bit_count)
|
||||
mask.flags.writeable = False
|
||||
return mask
|
||||
|
||||
|
||||
def coded_bit_count(information_bit_count: int, rate: str) -> int:
|
||||
"""Точное число передаваемых битов, включая шесть хвостовых битов кода."""
|
||||
mother_count = convolutional_fec.encoded_bit_count(information_bit_count)
|
||||
if rate not in PUNCTURE_PATTERNS:
|
||||
raise ValueError("неизвестная скорость кода")
|
||||
pattern = PUNCTURE_PATTERNS[rate]
|
||||
periods, remaining = divmod(mother_count, len(pattern))
|
||||
return periods * sum(pattern) + sum(pattern[:remaining])
|
||||
|
||||
|
||||
def effective_rate(information_bit_count: int, rate: str) -> float:
|
||||
"""Отношение полезных битов к реально передаваемым кодовым битам."""
|
||||
if information_bit_count <= 0:
|
||||
raise ValueError("число полезных битов должно быть положительным")
|
||||
return information_bit_count / coded_bit_count(information_bit_count, rate)
|
||||
|
||||
|
||||
def encode(information_bits: np.ndarray, rate: str) -> np.ndarray:
|
||||
"""Сначала применить штатный код, затем удалить позиции по маске."""
|
||||
mother_bits = convolutional_fec.encode(information_bits)
|
||||
return mother_bits[_mask(len(mother_bits), rate)]
|
||||
|
||||
|
||||
def depuncture_soft(
|
||||
soft_values: np.ndarray, information_bit_count: int, rate: str
|
||||
) -> np.ndarray:
|
||||
"""Вернуть стёртые позиции с нулевой мягкой метрикой."""
|
||||
values = np.asarray(soft_values, dtype=np.float64)
|
||||
if values.ndim != 1 or not np.all(np.isfinite(values)):
|
||||
raise ValueError("мягкие значения должны быть конечным одномерным массивом")
|
||||
mother_count = convolutional_fec.encoded_bit_count(information_bit_count)
|
||||
mask = _mask(mother_count, rate)
|
||||
if len(values) != int(np.count_nonzero(mask)):
|
||||
raise ValueError("неверная длина проколотого кодового слова")
|
||||
restored = np.zeros(mother_count, dtype=np.float64)
|
||||
restored[mask] = values
|
||||
return restored
|
||||
|
||||
|
||||
def decode_soft(
|
||||
soft_values: np.ndarray, information_bit_count: int, rate: str
|
||||
) -> np.ndarray:
|
||||
"""Декодировать через существующий 64-состояний декодер Витерби."""
|
||||
return convolutional_fec.viterbi_decode_soft(
|
||||
depuncture_soft(soft_values, information_bit_count, rate),
|
||||
information_bit_count,
|
||||
)
|
||||
50
protocol/video003_compact_header.py
Normal file
50
protocol/video003_compact_header.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Отдельная проверка 13-байтного видеозаголовка; формат VIDEO-002 не меняется."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
|
||||
from protocol.video_stream_transport import (
|
||||
MAX_FRAGMENTS, FrameType, VideoFragment, VideoTransportError,
|
||||
)
|
||||
|
||||
|
||||
VERSION = 2
|
||||
HEADER_FORMAT = "!BIIBBH"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 13 байт
|
||||
|
||||
|
||||
def encode(fragment: VideoFragment, max_phy_payload: int) -> bytes:
|
||||
"""Оставить поток и кадр 32-битными; кодировать count как count-1."""
|
||||
if not 0 <= fragment.stream_id <= 0xFFFFFFFF or not 0 <= fragment.frame_id <= 0xFFFFFFFF:
|
||||
raise VideoTransportError("номер потока или кадра вне диапазона")
|
||||
if not 1 <= fragment.fragment_count <= MAX_FRAGMENTS:
|
||||
raise VideoTransportError("неверное число фрагментов")
|
||||
if not 0 <= fragment.fragment_index < fragment.fragment_count:
|
||||
raise VideoTransportError("неверный номер фрагмента")
|
||||
if fragment.frame_type not in (FrameType.KEYFRAME, FrameType.DELTA_FRAME):
|
||||
raise VideoTransportError("неверный тип кадра")
|
||||
if not 0 <= fragment.age_ms <= 0xFFFF:
|
||||
raise VideoTransportError("неверный возраст кадра")
|
||||
if not isinstance(fragment.data, bytes) or not 1 <= len(fragment.data) <= max_phy_payload - HEADER_SIZE:
|
||||
raise VideoTransportError("данные не помещаются в PHY payload")
|
||||
flags = (VERSION << 4) | int(fragment.frame_type)
|
||||
header = struct.pack(HEADER_FORMAT, flags, fragment.stream_id, fragment.frame_id,
|
||||
fragment.fragment_index, fragment.fragment_count - 1,
|
||||
fragment.age_ms)
|
||||
return header + fragment.data
|
||||
|
||||
|
||||
def decode(wire: bytes, max_phy_payload: int) -> VideoFragment:
|
||||
"""Размер данных берётся из длины внешнего CRC-проверенного payload."""
|
||||
if not isinstance(wire, bytes) or not HEADER_SIZE < len(wire) <= max_phy_payload:
|
||||
raise VideoTransportError("длина видеопакета вне диапазона")
|
||||
flags, stream_id, frame_id, index, count_minus_one, age_ms = struct.unpack(
|
||||
HEADER_FORMAT, wire[:HEADER_SIZE])
|
||||
if flags >> 4 != VERSION or (flags & 0x0F) not in (1, 2):
|
||||
raise VideoTransportError("версия или тип кадра не поддерживается")
|
||||
count = count_minus_one + 1
|
||||
if index >= count:
|
||||
raise VideoTransportError("неверный номер фрагмента")
|
||||
return VideoFragment(stream_id, frame_id, index, count,
|
||||
FrameType(flags & 0x0F), age_ms, wire[HEADER_SIZE:])
|
||||
265
protocol/video003_modem.py
Normal file
265
protocol/video003_modem.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Отдельный программный BPSK/QPSK-тракт VIDEO-003 без обращения к SDR.
|
||||
|
||||
Приёмнику известны только PRBS7-преамбула и формат PHY-заголовка. Длина,
|
||||
кадровая граница, фаза и частотный сдвиг извлекаются из принятого сигнала;
|
||||
исходный payload приёмнику не передаётся.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
import math
|
||||
import struct
|
||||
from typing import Callable
|
||||
import zlib
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol import punctured_fec
|
||||
from protocol.bpsk_radio import root_raised_cosine_taps
|
||||
from protocol.packet import PacketError, build_packet, parse_packet
|
||||
|
||||
|
||||
PREAMBLE_SYMBOLS = 1280
|
||||
PHY_HEADER_SYMBOLS = 144
|
||||
MAX_FULL_PACKET_BYTES = 1024
|
||||
SAMPLE_RATE_HZ = 8_000_000
|
||||
RRC_ROLLOFF = 0.35
|
||||
RRC_SPAN_SYMBOLS = 10
|
||||
PHY_MAGIC = 0xD391
|
||||
PHY_VERSION = 1
|
||||
PHY_HEADER_FORMAT = ">HBHB"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def known_preamble_bits() -> np.ndarray:
|
||||
"""Тот же PRBS7 x^7+x^6+1, состояние 0x7f, что и в Lab044."""
|
||||
state = 0x7F
|
||||
result = np.empty(PREAMBLE_SYMBOLS, dtype=np.uint8)
|
||||
for index in range(PREAMBLE_SYMBOLS):
|
||||
result[index] = (state >> 6) & 1
|
||||
feedback = ((state >> 6) ^ (state >> 5)) & 1
|
||||
state = ((state << 1) & 0x7F) | feedback
|
||||
result.flags.writeable = False
|
||||
return result
|
||||
|
||||
|
||||
def bpsk_symbols(bits: np.ndarray) -> np.ndarray:
|
||||
values = np.asarray(bits, dtype=np.uint8)
|
||||
if values.ndim != 1 or not np.all((values == 0) | (values == 1)):
|
||||
raise ValueError("ожидается одномерный массив битов")
|
||||
return (2 * values.astype(np.float64) - 1).astype(np.complex128)
|
||||
|
||||
|
||||
def qpsk_symbols(bits: np.ndarray) -> np.ndarray:
|
||||
"""Gray: 00=(-1-j)/sqrt(2), 01=(-1+j), 11=(1+j), 10=(1-j)."""
|
||||
values = np.asarray(bits, dtype=np.uint8)
|
||||
if values.ndim != 1 or len(values) % 2 or not np.all((values == 0) | (values == 1)):
|
||||
raise ValueError("QPSK требует чётного числа битов {0,1}")
|
||||
pairs = values.reshape(-1, 2)
|
||||
return ((2 * pairs[:, 0].astype(np.float64) - 1)
|
||||
+ 1j * (2 * pairs[:, 1].astype(np.float64) - 1)) / math.sqrt(2)
|
||||
|
||||
|
||||
def qpsk_soft(symbols: np.ndarray) -> np.ndarray:
|
||||
"""Решения по I/Q, приведённые к идеальным значениям ±1."""
|
||||
values = np.asarray(symbols, dtype=np.complex128)
|
||||
soft = np.empty(values.size * 2, dtype=np.float64)
|
||||
soft[0::2] = values.real * math.sqrt(2)
|
||||
soft[1::2] = values.imag * math.sqrt(2)
|
||||
return soft
|
||||
|
||||
|
||||
def samples_per_symbol(symbol_rate: int) -> int:
|
||||
if symbol_rate not in (250_000, 500_000):
|
||||
raise ValueError("VIDEO-003 проверяет 250 или 500 ксимв/с")
|
||||
return SAMPLE_RATE_HZ // symbol_rate
|
||||
|
||||
|
||||
def rrc_roundtrip(symbols: np.ndarray, symbol_rate: int) -> np.ndarray:
|
||||
"""Формирование RRC, согласованный фильтр и выборка в известных тактах.
|
||||
|
||||
Это проверка формы при идеальной тактовой синхронизации, а не новый
|
||||
производственный приёмник.
|
||||
"""
|
||||
sps = samples_per_symbol(symbol_rate)
|
||||
taps = root_raised_cosine_taps(RRC_ROLLOFF, sps, RRC_SPAN_SYMBOLS)
|
||||
upsampled = np.zeros(len(symbols) * sps, dtype=np.complex128)
|
||||
upsampled[::sps] = symbols
|
||||
shaped = np.convolve(upsampled, taps, mode="full")
|
||||
matched = np.convolve(shaped, taps, mode="full")
|
||||
group_delay = len(taps) - 1
|
||||
gain = float(np.sum(taps * taps))
|
||||
return matched[group_delay:group_delay + len(symbols) * sps:sps] / gain
|
||||
|
||||
|
||||
def _phy_header(packet_bytes: int) -> np.ndarray:
|
||||
if not 12 <= packet_bytes <= MAX_FULL_PACKET_BYTES:
|
||||
raise ValueError("полный пакет вне предела PHY")
|
||||
fields = struct.pack(">HBH", PHY_MAGIC, PHY_VERSION, packet_bytes)
|
||||
checksum = zlib.crc32(fields) & 0xFF
|
||||
bits = np.unpackbits(np.frombuffer(fields + bytes([checksum]), dtype=np.uint8))
|
||||
return np.repeat(bits, 3)
|
||||
|
||||
|
||||
def _decode_phy_header(soft: np.ndarray) -> int | None:
|
||||
if len(soft) < PHY_HEADER_SYMBOLS or not np.all(np.isfinite(soft[:PHY_HEADER_SYMBOLS])):
|
||||
return None
|
||||
bits = (soft[:PHY_HEADER_SYMBOLS].reshape(48, 3).sum(axis=1) >= 0).astype(np.uint8)
|
||||
try:
|
||||
magic, version, length, checksum = struct.unpack(PHY_HEADER_FORMAT, np.packbits(bits).tobytes())
|
||||
except struct.error:
|
||||
return None
|
||||
fields = struct.pack(">HBH", magic, version, length)
|
||||
if (magic != PHY_MAGIC or version != PHY_VERSION
|
||||
or checksum != (zlib.crc32(fields) & 0xFF)
|
||||
or not 12 <= length <= MAX_FULL_PACKET_BYTES):
|
||||
return None
|
||||
return length
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfflineFrame:
|
||||
packet: bytes
|
||||
coded_bits: np.ndarray
|
||||
symbols: np.ndarray
|
||||
modulation: str
|
||||
rate: str
|
||||
|
||||
|
||||
def build_frame(payload: bytes, modulation: str, rate: str,
|
||||
sequence_number: int = 0) -> OfflineFrame:
|
||||
"""Сформировать отдельный смешанный кадр с BPSK-пилотом и заголовком."""
|
||||
if modulation not in ("BPSK", "QPSK"):
|
||||
raise ValueError("неизвестная модуляция")
|
||||
packet = build_packet(payload, message_type=3, sequence_number=sequence_number)
|
||||
if len(packet) > MAX_FULL_PACKET_BYTES:
|
||||
raise ValueError("полный пакет превышает 1024 байта")
|
||||
information = np.unpackbits(np.frombuffer(packet, dtype=np.uint8))
|
||||
coded = punctured_fec.encode(information, rate)
|
||||
if modulation == "QPSK":
|
||||
padded = np.pad(coded, (0, len(coded) % 2))
|
||||
body = qpsk_symbols(padded)
|
||||
else:
|
||||
body = bpsk_symbols(coded)
|
||||
prefix = bpsk_symbols(np.concatenate((known_preamble_bits(), _phy_header(len(packet)))))
|
||||
return OfflineFrame(packet, coded, np.concatenate((prefix, body)), modulation, rate)
|
||||
|
||||
|
||||
def _find_preamble(received: np.ndarray, maximum_start: int) -> int | None:
|
||||
"""Найти начало по дифференциальной корреляции известного пилота."""
|
||||
reference = bpsk_symbols(known_preamble_bits())
|
||||
differential_reference = reference[1:] * np.conj(reference[:-1])
|
||||
last = min(maximum_start, len(received) - PREAMBLE_SYMBOLS)
|
||||
if last < 0:
|
||||
return None
|
||||
best_start, best_score = None, 0.0
|
||||
for start in range(last + 1):
|
||||
section = received[start:start + PREAMBLE_SYMBOLS]
|
||||
products = section[1:] * np.conj(section[:-1])
|
||||
energy = float(np.sum(np.abs(products)))
|
||||
score = abs(np.vdot(differential_reference, products)) / max(energy, 1e-12)
|
||||
if score > best_score:
|
||||
best_start, best_score = start, score
|
||||
return best_start if best_score >= 0.25 else None
|
||||
|
||||
|
||||
def _estimate_phase_frequency(received: np.ndarray, known: np.ndarray) -> tuple[float, float]:
|
||||
"""Оценить фазу и приращение фазы только по известным символам."""
|
||||
despread = received[:len(known)] * np.conj(known)
|
||||
lag = min(256, len(known) // 4)
|
||||
delayed = despread[lag:] * np.conj(despread[:-lag])
|
||||
increment = float(np.angle(np.sum(delayed)) / lag)
|
||||
indexes = np.arange(len(known), dtype=np.float64)
|
||||
residual = despread * np.exp(-1j * increment * indexes)
|
||||
half = len(known) // 2
|
||||
left = np.sum(residual[:half])
|
||||
right = np.sum(residual[half:])
|
||||
increment += float(np.angle(right * np.conj(left)) / half)
|
||||
phase = float(np.angle(np.sum(despread * np.exp(-1j * increment * indexes))))
|
||||
return phase, increment
|
||||
|
||||
|
||||
def _correct(received: np.ndarray, phase: float, increment: float) -> np.ndarray:
|
||||
indexes = np.arange(len(received), dtype=np.float64)
|
||||
return received * np.exp(-1j * (phase + increment * indexes))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OfflineReception:
|
||||
preamble_found: bool
|
||||
phy_header_pass: bool
|
||||
crc_pass: bool
|
||||
packet: bytes | None
|
||||
payload: bytes | None
|
||||
start_symbol: int | None
|
||||
frequency_offset_hz: float | None
|
||||
initial_phase_rad: float | None
|
||||
coded_soft: np.ndarray | None
|
||||
|
||||
|
||||
def receive_frame(
|
||||
received_symbols: np.ndarray,
|
||||
modulation: str,
|
||||
rate: str,
|
||||
symbol_rate: int,
|
||||
maximum_start: int = 64,
|
||||
fec_decoder: Callable[[np.ndarray, int, str], np.ndarray] | None = None,
|
||||
) -> OfflineReception:
|
||||
"""Строго декодировать без подсказки о длине, payload и фазе передатчика."""
|
||||
samples_per_symbol(symbol_rate)
|
||||
if modulation not in ("BPSK", "QPSK") or rate not in punctured_fec.PUNCTURE_PATTERNS:
|
||||
raise ValueError("неизвестный режим")
|
||||
received = np.asarray(received_symbols, dtype=np.complex128)
|
||||
if received.ndim != 1:
|
||||
raise ValueError("сигнал должен быть одномерным массивом")
|
||||
search_end = min(len(received), maximum_start + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS)
|
||||
if not np.all(np.isfinite(received[:search_end])):
|
||||
raise ValueError("кандидат сигнала содержит неконечные значения")
|
||||
empty = OfflineReception(False, False, False, None, None, None, None, None, None)
|
||||
start = _find_preamble(received, maximum_start)
|
||||
if start is None:
|
||||
return empty
|
||||
frame = received[start:]
|
||||
pilot = bpsk_symbols(known_preamble_bits())
|
||||
phase, increment = _estimate_phase_frequency(frame[:PREAMBLE_SYMBOLS], pilot)
|
||||
header_end = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
|
||||
corrected = _correct(frame[:header_end], phase, increment)
|
||||
header_start = PREAMBLE_SYMBOLS
|
||||
packet_bytes = _decode_phy_header(corrected[header_start:header_start + PHY_HEADER_SYMBOLS].real)
|
||||
if packet_bytes is None:
|
||||
return OfflineReception(True, False, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
known = np.concatenate((pilot, bpsk_symbols(_phy_header(packet_bytes))))
|
||||
phase, increment = _estimate_phase_frequency(frame[:len(known)], known)
|
||||
corrected_header = _correct(frame[:header_end], phase, increment)
|
||||
if _decode_phy_header(corrected_header[header_start:header_end].real) != packet_bytes:
|
||||
return OfflineReception(True, False, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
information_bit_count = packet_bytes * 8
|
||||
coded_count = punctured_fec.coded_bit_count(information_bit_count, rate)
|
||||
body_start = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
|
||||
bits_per_symbol = 1 if modulation == "BPSK" else 2
|
||||
body_count = math.ceil(coded_count / bits_per_symbol)
|
||||
frame_end = body_start + body_count
|
||||
if len(frame) < frame_end:
|
||||
return OfflineReception(True, True, False, None, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, None)
|
||||
if not np.all(np.isfinite(frame[body_start:frame_end])):
|
||||
raise ValueError("тело сигнала содержит неконечные значения")
|
||||
corrected = _correct(frame[body_start:frame_end],
|
||||
phase + increment * body_start, increment)
|
||||
body = corrected
|
||||
soft = body.real if modulation == "BPSK" else qpsk_soft(body)
|
||||
soft = soft[:coded_count].copy()
|
||||
decoded = (punctured_fec.decode_soft if fec_decoder is None else fec_decoder)(
|
||||
soft, information_bit_count, rate)
|
||||
packet = np.packbits(decoded).tobytes()
|
||||
try:
|
||||
parsed = parse_packet(packet)
|
||||
except PacketError:
|
||||
return OfflineReception(True, True, False, packet, None, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, soft)
|
||||
return OfflineReception(True, True, True, packet, parsed.payload, start,
|
||||
increment * symbol_rate / (2 * math.pi), phase, soft)
|
||||
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()
|
||||
68
protocol/video004_viterbi.py
Normal file
68
protocol/video004_viterbi.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""CPU JIT для той же 64-состояний решётки Витерби VIDEO-004.
|
||||
|
||||
Режим выбирается вызывающим кодом до приёма пакета. При отсутствии Numba
|
||||
этот модуль не подменяет результат медленным декодером после ошибки CRC.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from protocol import convolutional_fec, punctured_fec
|
||||
|
||||
try:
|
||||
from numba import njit
|
||||
except ImportError:
|
||||
njit = None
|
||||
|
||||
|
||||
PREDECESSORS = convolutional_fec._PREDECESSORS
|
||||
BRANCH_SYMBOLS = convolutional_fec._BRANCH_SYMBOLS
|
||||
|
||||
|
||||
def _trellis(soft_values: np.ndarray, information_bit_count: int,
|
||||
predecessors: np.ndarray, branch_symbols: np.ndarray) -> np.ndarray:
|
||||
"""Те же метрики, порядок сложения и выбор ветви 0 при равенстве."""
|
||||
step_count = information_bit_count + convolutional_fec.TAIL_BIT_COUNT
|
||||
received = soft_values.reshape(step_count, 2)
|
||||
path_metric = np.full(convolutional_fec.STATE_COUNT, 1e18, dtype=np.float64)
|
||||
path_metric[0] = 0.0
|
||||
next_metric = np.empty(convolutional_fec.STATE_COUNT, dtype=np.float64)
|
||||
choices = np.empty((step_count, convolutional_fec.STATE_COUNT), dtype=np.uint8)
|
||||
for step in range(step_count):
|
||||
received_0 = received[step, 0]
|
||||
received_1 = received[step, 1]
|
||||
for state in range(convolutional_fec.STATE_COUNT):
|
||||
difference_00 = received_0 - branch_symbols[state, 0, 0]
|
||||
difference_01 = received_1 - branch_symbols[state, 0, 1]
|
||||
difference_10 = received_0 - branch_symbols[state, 1, 0]
|
||||
difference_11 = received_1 - branch_symbols[state, 1, 1]
|
||||
first = (path_metric[predecessors[state, 0]]
|
||||
+ difference_00 ** 2 + difference_01 ** 2)
|
||||
second = (path_metric[predecessors[state, 1]]
|
||||
+ difference_10 ** 2 + difference_11 ** 2)
|
||||
take_second = second < first
|
||||
choices[step, state] = take_second
|
||||
next_metric[state] = second if take_second else first
|
||||
path_metric, next_metric = next_metric, path_metric
|
||||
decoded = np.empty(step_count, dtype=np.uint8)
|
||||
current_state = 0
|
||||
for step in range(step_count - 1, -1, -1):
|
||||
branch = choices[step, current_state]
|
||||
decoded[step] = current_state >> (convolutional_fec.MEMORY - 1)
|
||||
current_state = predecessors[current_state, branch]
|
||||
return decoded[:information_bit_count]
|
||||
|
||||
|
||||
COMPILED_TRELLIS = (njit(cache=False, nogil=True, fastmath=False)(_trellis)
|
||||
if njit is not None else None)
|
||||
|
||||
|
||||
def decode_soft(soft_values: np.ndarray, information_bit_count: int,
|
||||
rate: str) -> np.ndarray:
|
||||
"""Проверить вход и декодировать проколотое слово без запасного алгоритма."""
|
||||
if COMPILED_TRELLIS is None:
|
||||
raise RuntimeError("Numba недоступна для VIDEO-004")
|
||||
restored = punctured_fec.depuncture_soft(soft_values, information_bit_count, rate)
|
||||
return COMPILED_TRELLIS(restored, int(information_bit_count),
|
||||
PREDECESSORS, BRANCH_SYMBOLS)
|
||||
331
protocol/video_stream_transport.py
Normal file
331
protocol/video_stream_transport.py
Normal file
@@ -0,0 +1,331 @@
|
||||
"""Ограниченный транспорт свежих H.264 кадров поверх уже проверенных пакетов.
|
||||
|
||||
Модуль получает только полезную нагрузку после строгого PHY/FEC/CRC. Он не
|
||||
запускает радио и не заменяет существующие заголовки или исправляющий код.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
import math
|
||||
import struct
|
||||
|
||||
|
||||
VERSION = 1
|
||||
HEADER_FORMAT = "!BIIHHHH"
|
||||
HEADER_SIZE = struct.calcsize(HEADER_FORMAT) # 17 байт
|
||||
MAX_FRAME_BYTES = 131_072
|
||||
MAX_FRAGMENTS = 256
|
||||
|
||||
|
||||
class VideoTransportError(ValueError):
|
||||
"""Некорректный фрагмент или состояние видеотранспорта."""
|
||||
|
||||
|
||||
class FrameType(IntEnum):
|
||||
KEYFRAME = 1
|
||||
DELTA_FRAME = 2
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoFrame:
|
||||
stream_id: int
|
||||
frame_id: int
|
||||
frame_type: FrameType
|
||||
capture_ms: int
|
||||
data: bytes
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VideoFragment:
|
||||
stream_id: int
|
||||
frame_id: int
|
||||
fragment_index: int
|
||||
fragment_count: int
|
||||
frame_type: FrameType
|
||||
age_ms: int
|
||||
data: bytes
|
||||
|
||||
|
||||
def _validate_uint(value: int, maximum: int, name: str) -> None:
|
||||
if not isinstance(value, int) or not 0 <= value <= maximum:
|
||||
raise VideoTransportError(f"{name} вне допустимого диапазона")
|
||||
|
||||
|
||||
def is_newer(frame_id: int, reference: int) -> bool:
|
||||
"""Сравнить 32-битные номера с защитой от старых пакетов при переходе через ноль."""
|
||||
difference = (frame_id - reference) & 0xFFFFFFFF
|
||||
return 0 < difference < 0x80000000
|
||||
|
||||
|
||||
def encode_fragment(fragment: VideoFragment, max_phy_payload: int) -> bytes:
|
||||
"""Упаковать фрагмент в полезную нагрузку проверенного пакета."""
|
||||
_validate_uint(fragment.stream_id, 0xFFFFFFFF, "stream_id")
|
||||
_validate_uint(fragment.frame_id, 0xFFFFFFFF, "frame_id")
|
||||
_validate_uint(fragment.fragment_count, MAX_FRAGMENTS, "fragment_count")
|
||||
if fragment.fragment_count == 0:
|
||||
raise VideoTransportError("fragment_count равен нулю")
|
||||
_validate_uint(fragment.fragment_index, fragment.fragment_count - 1, "fragment_index")
|
||||
_validate_uint(fragment.age_ms, 0xFFFF, "age_ms")
|
||||
if fragment.frame_type not in (FrameType.KEYFRAME, FrameType.DELTA_FRAME):
|
||||
raise VideoTransportError("неизвестный тип кадра")
|
||||
if not isinstance(fragment.data, bytes) or not fragment.data:
|
||||
raise VideoTransportError("пустые или некорректные данные")
|
||||
if len(fragment.data) > min(0xFFFF, max_phy_payload - HEADER_SIZE):
|
||||
raise VideoTransportError("фрагмент не помещается в пакет")
|
||||
flags = (VERSION << 4) | int(fragment.frame_type)
|
||||
header = struct.pack(HEADER_FORMAT, flags, fragment.stream_id,
|
||||
fragment.frame_id, fragment.fragment_index,
|
||||
fragment.fragment_count, fragment.age_ms,
|
||||
len(fragment.data))
|
||||
return header + fragment.data
|
||||
|
||||
|
||||
def decode_fragment(wire: bytes, max_phy_payload: int) -> VideoFragment:
|
||||
"""Проверить длины и поля после внешнего CRC, не восстанавливая данные."""
|
||||
if not isinstance(wire, bytes) or not HEADER_SIZE < len(wire) <= max_phy_payload:
|
||||
raise VideoTransportError("длина видеопакета вне допустимого диапазона")
|
||||
flags, stream_id, frame_id, index, count, age_ms, length = struct.unpack(
|
||||
HEADER_FORMAT, wire[:HEADER_SIZE])
|
||||
if flags >> 4 != VERSION or (flags & 0x0F) not in (1, 2):
|
||||
raise VideoTransportError("версия или тип кадра не поддерживается")
|
||||
if not 1 <= count <= MAX_FRAGMENTS or index >= count:
|
||||
raise VideoTransportError("неверная нумерация фрагментов")
|
||||
if length == 0 or length != len(wire) - HEADER_SIZE:
|
||||
raise VideoTransportError("payload_length не совпадает с размером пакета")
|
||||
return VideoFragment(stream_id, frame_id, index, count,
|
||||
FrameType(flags & 0x0F), age_ms, wire[HEADER_SIZE:])
|
||||
|
||||
|
||||
def split_frame(frame: VideoFrame, max_phy_payload: int,
|
||||
age_ms: int = 0) -> tuple[VideoFragment, ...]:
|
||||
"""Разбить одно непустое изображение H.264 без потери байтов."""
|
||||
if not isinstance(frame.data, bytes) or not 1 <= len(frame.data) <= MAX_FRAME_BYTES:
|
||||
raise VideoTransportError("размер кадра вне допустимого диапазона")
|
||||
capacity = max_phy_payload - HEADER_SIZE
|
||||
if capacity <= 0:
|
||||
raise VideoTransportError("пакет меньше заголовка")
|
||||
count = math.ceil(len(frame.data) / capacity)
|
||||
if count > MAX_FRAGMENTS:
|
||||
raise VideoTransportError("слишком много фрагментов")
|
||||
return tuple(VideoFragment(frame.stream_id, frame.frame_id, index, count,
|
||||
frame.frame_type, age_ms,
|
||||
frame.data[index * capacity:(index + 1) * capacity])
|
||||
for index in range(count))
|
||||
|
||||
|
||||
@dataclass
|
||||
class _QueuedFrame:
|
||||
frame: VideoFrame
|
||||
parts: tuple[VideoFragment, ...]
|
||||
next_index: int = 0
|
||||
|
||||
|
||||
class BoundedFrameQueue:
|
||||
"""Очередь ограничивает число кадров и срок каждого фрагмента."""
|
||||
|
||||
def __init__(self, max_phy_payload: int, max_frames: int = 2,
|
||||
max_age_ms: int = 300) -> None:
|
||||
if max_frames < 1 or not 1 <= max_age_ms <= 0xFFFF:
|
||||
raise ValueError("недопустимые ограничения очереди")
|
||||
self.max_phy_payload = max_phy_payload
|
||||
self.max_frames = max_frames
|
||||
self.max_age_ms = max_age_ms
|
||||
self._frames: deque[_QueuedFrame] = deque()
|
||||
self.max_depth = 0
|
||||
self.max_oldest_age_ms = 0
|
||||
self.stale_frame_drops = 0
|
||||
self.capacity_frame_drops = 0
|
||||
self.sent_frames = 0
|
||||
self._last_capture_ms = -1
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return len(self._frames)
|
||||
|
||||
def _observe(self, now_ms: int) -> None:
|
||||
self.max_depth = max(self.max_depth, self.depth)
|
||||
if self._frames:
|
||||
self.max_oldest_age_ms = max(
|
||||
self.max_oldest_age_ms, now_ms - self._frames[0].frame.capture_ms)
|
||||
|
||||
def enqueue(self, frame: VideoFrame, now_ms: int) -> None:
|
||||
if frame.capture_ms <= self._last_capture_ms or frame.capture_ms > now_ms:
|
||||
raise VideoTransportError("время создания кадров должно возрастать")
|
||||
self._last_capture_ms = frame.capture_ms
|
||||
parts = split_frame(frame, self.max_phy_payload)
|
||||
self.expire(now_ms)
|
||||
if now_ms - frame.capture_ms >= self.max_age_ms:
|
||||
self.stale_frame_drops += 1
|
||||
return
|
||||
if len(self._frames) >= self.max_frames:
|
||||
self._frames.popleft()
|
||||
self.capacity_frame_drops += 1
|
||||
self._frames.append(_QueuedFrame(frame, parts))
|
||||
self._observe(now_ms)
|
||||
|
||||
def expire(self, now_ms: int, packet_end_ms: float | None = None) -> None:
|
||||
retained: deque[_QueuedFrame] = deque()
|
||||
for item in self._frames:
|
||||
deadline = item.frame.capture_ms + self.max_age_ms
|
||||
if (now_ms >= deadline or (packet_end_ms is not None
|
||||
and math.ceil(packet_end_ms) >= deadline)):
|
||||
self.stale_frame_drops += 1
|
||||
else:
|
||||
retained.append(item)
|
||||
self._frames = retained
|
||||
self._observe(now_ms)
|
||||
|
||||
def next_fragment(self, now_ms: int, packet_end_ms: float) -> VideoFragment | None:
|
||||
self.expire(now_ms, packet_end_ms)
|
||||
if not self._frames:
|
||||
return None
|
||||
item = self._frames[0]
|
||||
part = item.parts[item.next_index]
|
||||
age_ms = math.ceil(packet_end_ms - item.frame.capture_ms)
|
||||
if age_ms > 0xFFFF:
|
||||
self._frames.popleft()
|
||||
self.stale_frame_drops += 1
|
||||
return None
|
||||
self._observe(now_ms)
|
||||
return VideoFragment(part.stream_id, part.frame_id, part.fragment_index,
|
||||
part.fragment_count, part.frame_type, age_ms, part.data)
|
||||
|
||||
def mark_sent(self) -> None:
|
||||
if not self._frames:
|
||||
raise VideoTransportError("нет активного кадра")
|
||||
self._frames[0].next_index += 1
|
||||
if self._frames[0].next_index == len(self._frames[0].parts):
|
||||
self._frames.popleft()
|
||||
self.sent_frames += 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Assembly:
|
||||
frame_type: FrameType
|
||||
count: int
|
||||
deadline_ms: int
|
||||
first_arrival_ms: int
|
||||
parts: dict[int, bytes] = field(default_factory=dict)
|
||||
size: int = 0
|
||||
|
||||
|
||||
class FrameReassembler:
|
||||
"""Собирать только свежие кадры выбранного сеанса, без ожидания старых."""
|
||||
|
||||
def __init__(self, stream_id: int, max_phy_payload: int,
|
||||
max_pending_frames: int = 2, max_age_ms: int = 300) -> None:
|
||||
_validate_uint(stream_id, 0xFFFFFFFF, "stream_id")
|
||||
if max_pending_frames < 1 or not 1 <= max_age_ms <= 0xFFFF:
|
||||
raise ValueError("недопустимые ограничения сборщика")
|
||||
self.stream_id = stream_id
|
||||
self.max_phy_payload = max_phy_payload
|
||||
self.max_pending_frames = max_pending_frames
|
||||
self.max_age_ms = max_age_ms
|
||||
self._pending: dict[int, _Assembly] = {}
|
||||
self._closed: deque[int] = deque(maxlen=1024)
|
||||
self.last_completed_id: int | None = None
|
||||
self.latest_admitted_id: int | None = None
|
||||
self.max_depth = 0
|
||||
self.expired_frames = 0
|
||||
self.capacity_drops = 0
|
||||
self.rejected_fragments = 0
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return len(self._pending)
|
||||
|
||||
def expire(self, now_ms: int) -> tuple[int, ...]:
|
||||
expired = tuple(frame_id for frame_id, item in self._pending.items()
|
||||
if now_ms >= item.deadline_ms)
|
||||
for frame_id in expired:
|
||||
del self._pending[frame_id]
|
||||
self._closed.append(frame_id)
|
||||
self.expired_frames += 1
|
||||
return expired
|
||||
|
||||
def offer(self, wire: bytes, now_ms: int) -> VideoFrame | None:
|
||||
self.expire(now_ms)
|
||||
fragment = decode_fragment(wire, self.max_phy_payload)
|
||||
return self._offer_fragment(fragment, now_ms)
|
||||
|
||||
def offer_fragment(self, fragment: VideoFragment, now_ms: int) -> VideoFrame | None:
|
||||
"""Принять уже проверенный фрагмент нового видеоформата без сериализации."""
|
||||
self.expire(now_ms)
|
||||
if (not isinstance(fragment, VideoFragment)
|
||||
or not isinstance(fragment.data, bytes) or not fragment.data
|
||||
or len(fragment.data) > self.max_phy_payload - HEADER_SIZE
|
||||
or not 1 <= fragment.fragment_count <= MAX_FRAGMENTS
|
||||
or not 0 <= fragment.fragment_index < fragment.fragment_count
|
||||
or fragment.frame_type not in (FrameType.KEYFRAME, FrameType.DELTA_FRAME)
|
||||
or not 0 <= fragment.age_ms <= 0xFFFF
|
||||
or not 0 <= fragment.stream_id <= 0xFFFFFFFF
|
||||
or not 0 <= fragment.frame_id <= 0xFFFFFFFF):
|
||||
raise VideoTransportError("некорректный проверенный видеофрагмент")
|
||||
return self._offer_fragment(fragment, now_ms)
|
||||
|
||||
def _offer_fragment(self, fragment: VideoFragment, now_ms: int) -> VideoFrame | None:
|
||||
if (fragment.stream_id != self.stream_id or fragment.frame_id in self._closed
|
||||
or (self.last_completed_id is not None
|
||||
and not is_newer(fragment.frame_id, self.last_completed_id))):
|
||||
self.rejected_fragments += 1
|
||||
return None
|
||||
if fragment.age_ms >= self.max_age_ms:
|
||||
self.rejected_fragments += 1
|
||||
return None
|
||||
deadline = now_ms + self.max_age_ms - fragment.age_ms
|
||||
item = self._pending.get(fragment.frame_id)
|
||||
if item is None:
|
||||
if (self.latest_admitted_id is not None
|
||||
and not is_newer(fragment.frame_id, self.latest_admitted_id)
|
||||
and not 0 < ((self.latest_admitted_id - fragment.frame_id) & 0xFFFFFFFF)
|
||||
< self.max_pending_frames):
|
||||
self.rejected_fragments += 1
|
||||
return None
|
||||
if len(self._pending) >= self.max_pending_frames:
|
||||
oldest_id = min(self._pending,
|
||||
key=lambda frame_id: self._pending[frame_id].first_arrival_ms)
|
||||
if not is_newer(fragment.frame_id, oldest_id):
|
||||
self.rejected_fragments += 1
|
||||
return None
|
||||
del self._pending[oldest_id]
|
||||
self._closed.append(oldest_id)
|
||||
self.capacity_drops += 1
|
||||
item = _Assembly(fragment.frame_type, fragment.fragment_count,
|
||||
deadline, now_ms)
|
||||
self._pending[fragment.frame_id] = item
|
||||
if (self.latest_admitted_id is None
|
||||
or is_newer(fragment.frame_id, self.latest_admitted_id)):
|
||||
self.latest_admitted_id = fragment.frame_id
|
||||
self.max_depth = max(self.max_depth, len(self._pending))
|
||||
elif item.frame_type != fragment.frame_type or item.count != fragment.fragment_count:
|
||||
del self._pending[fragment.frame_id]
|
||||
self._closed.append(fragment.frame_id)
|
||||
self.rejected_fragments += 1
|
||||
raise VideoTransportError("противоречивые метаданные кадра")
|
||||
item.deadline_ms = min(item.deadline_ms, deadline)
|
||||
previous = item.parts.get(fragment.fragment_index)
|
||||
if previous is not None:
|
||||
if previous != fragment.data:
|
||||
del self._pending[fragment.frame_id]
|
||||
self._closed.append(fragment.frame_id)
|
||||
self.rejected_fragments += 1
|
||||
raise VideoTransportError("противоречивые данные фрагмента")
|
||||
return None
|
||||
item.parts[fragment.fragment_index] = fragment.data
|
||||
item.size += len(fragment.data)
|
||||
if item.size > MAX_FRAME_BYTES:
|
||||
del self._pending[fragment.frame_id]
|
||||
self._closed.append(fragment.frame_id)
|
||||
self.rejected_fragments += 1
|
||||
raise VideoTransportError("кадр превышает ограничение памяти")
|
||||
if len(item.parts) != item.count:
|
||||
return None
|
||||
data = b"".join(item.parts[index] for index in range(item.count))
|
||||
del self._pending[fragment.frame_id]
|
||||
self._closed.append(fragment.frame_id)
|
||||
self.last_completed_id = fragment.frame_id
|
||||
return VideoFrame(fragment.stream_id, fragment.frame_id,
|
||||
fragment.frame_type, now_ms - fragment.age_ms, data)
|
||||
Reference in New Issue
Block a user