Complete VIDEO-001 through VIDEO-004 software video studies

This commit is contained in:
LittleSam129
2026-09-16 18:27:35 +03:00
parent af7c05f2da
commit 95f664076a
403 changed files with 392009 additions and 0 deletions

81
protocol/punctured_fec.py Normal file
View 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,
)

View 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
View 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)

View 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()

View 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)

View 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)