Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
41
tests/test_video002_codec.py
Normal file
41
tests/test_video002_codec.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Потеря реального H.264 кадра и восстановление декодера на следующем IDR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
|
||||
from protocol.video_stream_transport import FrameType, VideoFrame
|
||||
from tools.video002_simulation import simulate
|
||||
from tools.video_compare import new_h264_encoder
|
||||
|
||||
|
||||
def test_h264_recovers_after_lost_keyframe_and_delta() -> None:
|
||||
fps = 5
|
||||
encoder, _ = new_h264_encoder(64, 48, fps, 160_000)
|
||||
frames = []
|
||||
for index in range(15):
|
||||
pixels = np.zeros((48, 64, 3), dtype=np.uint8)
|
||||
pixels[:, :, 1] = (index * 13) % 255
|
||||
pixels[10:22, index * 3:index * 3 + 12, 0] = 230
|
||||
raw = av.VideoFrame.from_ndarray(pixels, format="rgb24").reformat(format="yuv420p")
|
||||
raw.pts = index
|
||||
packets = encoder.encode(raw)
|
||||
assert len(packets) == 1
|
||||
packet = packets[0]
|
||||
frames.append(VideoFrame(17, index,
|
||||
FrameType.KEYFRAME if packet.is_keyframe
|
||||
else FrameType.DELTA_FRAME,
|
||||
index * 200, bytes(packet)))
|
||||
assert not encoder.encode(None)
|
||||
assert [item.frame_id for item in frames if item.frame_type == FrameType.KEYFRAME] == [0, 5, 10]
|
||||
link = ("SOFTWARE_2MBPS", None, 2_000_000, 1024, 0)
|
||||
clean = simulate(frames, fps, link, ("random", 0))
|
||||
key_lost = simulate(frames, fps, link, ("keyframe", 1))
|
||||
delta_lost = simulate(frames, fps, link, ("delta", 1))
|
||||
assert clean["displayed_frame_ids"] == list(range(15))
|
||||
assert key_lost["displayed_frame_ids"] == list(range(5)) + list(range(10, 15))
|
||||
assert delta_lost["displayed_frame_ids"] == list(range(6)) + list(range(10, 15))
|
||||
assert key_lost["keyframe_sha256_by_id"]["10"] == clean["keyframe_sha256_by_id"]["10"]
|
||||
assert key_lost["unique_decoded_images"] == key_lost["displayed_frames"]
|
||||
assert key_lost["no_old_frame_replay"] and delta_lost["no_old_frame_replay"]
|
||||
130
tests/test_video002_transport.py
Normal file
130
tests/test_video002_transport.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Проверки целостности и ограничения возраста видеокадра."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.video_stream_transport import (
|
||||
BoundedFrameQueue, FrameReassembler, FrameType, VideoFrame,
|
||||
VideoTransportError, decode_fragment, encode_fragment, is_newer,
|
||||
split_frame,
|
||||
)
|
||||
from tools.video002_radio_budget import estimate, margin_fits
|
||||
|
||||
|
||||
def frame(number: int = 1, capture_ms: int = 0, size: int = 600) -> VideoFrame:
|
||||
return VideoFrame(7, number, FrameType.KEYFRAME, capture_ms, bytes(range(256)) *
|
||||
(size // 256) + bytes(range(size % 256)))
|
||||
|
||||
|
||||
def test_header_roundtrip_and_corruption() -> None:
|
||||
part = split_frame(frame(), 256)[0]
|
||||
wire = encode_fragment(part, 256)
|
||||
assert len(wire) == 256
|
||||
assert decode_fragment(wire, 256) == part
|
||||
with pytest.raises(VideoTransportError):
|
||||
decode_fragment(wire[:-1], 256)
|
||||
with pytest.raises(VideoTransportError):
|
||||
decode_fragment(bytes([0x21]) + wire[1:], 256)
|
||||
with pytest.raises(VideoTransportError):
|
||||
encode_fragment(replace(part, fragment_index=part.fragment_count), 256)
|
||||
|
||||
|
||||
def test_out_of_order_reassembly_no_partial_or_replay() -> None:
|
||||
source = frame()
|
||||
parts = [encode_fragment(item, 256) for item in split_frame(source, 256)]
|
||||
receiver = FrameReassembler(7, 256)
|
||||
assert receiver.offer(parts[2], 20) is None
|
||||
assert receiver.offer(parts[0], 21) is None
|
||||
assert receiver.offer(parts[0], 22) is None
|
||||
complete = receiver.offer(parts[1], 23)
|
||||
assert complete is not None and complete.data == source.data
|
||||
assert receiver.offer(parts[1], 24) is None
|
||||
assert receiver.rejected_fragments == 1
|
||||
|
||||
|
||||
def test_incomplete_frame_expires_and_new_frame_advances() -> None:
|
||||
receiver = FrameReassembler(7, 256, max_age_ms=100)
|
||||
first = encode_fragment(split_frame(frame(1), 256)[0], 256)
|
||||
assert receiver.offer(first, 0) is None
|
||||
assert receiver.expire(100) == (1,)
|
||||
assert receiver.offer(first, 101) is None
|
||||
next_frame = frame(2, 101, 25)
|
||||
complete = receiver.offer(encode_fragment(split_frame(next_frame, 256)[0], 256), 102)
|
||||
assert complete is not None and complete.data == next_frame.data
|
||||
|
||||
|
||||
def test_sender_bounded_queue_discards_old_frame() -> None:
|
||||
sender = BoundedFrameQueue(256, max_frames=2, max_age_ms=100)
|
||||
sender.enqueue(frame(1, 0), 0)
|
||||
sender.enqueue(frame(2, 20), 20)
|
||||
sender.enqueue(frame(3, 40), 40)
|
||||
assert sender.depth == 2 and sender.capacity_frame_drops == 1
|
||||
part = sender.next_fragment(40, 50)
|
||||
assert part is not None and part.frame_id == 2
|
||||
sender.expire(120)
|
||||
assert sender.stale_frame_drops == 1
|
||||
assert sender.next_fragment(120, 130).frame_id == 3
|
||||
sender.expire(140)
|
||||
assert sender.depth == 0 and sender.stale_frame_drops == 2
|
||||
|
||||
|
||||
def test_fractional_packet_end_drops_before_receiver_age_rounding() -> None:
|
||||
sender = BoundedFrameQueue(256, max_age_ms=300)
|
||||
sender.enqueue(frame(1, 0, 25), 0)
|
||||
assert sender.next_fragment(299, 299.1) is None
|
||||
assert sender.stale_frame_drops == 1
|
||||
|
||||
|
||||
def test_conflicting_fragment_closes_frame() -> None:
|
||||
receiver = FrameReassembler(7, 256)
|
||||
original = split_frame(frame(), 256)[0]
|
||||
receiver.offer(encode_fragment(original, 256), 0)
|
||||
bad = replace(original, data=b"X" + original.data[1:])
|
||||
with pytest.raises(VideoTransportError):
|
||||
receiver.offer(encode_fragment(bad, 256), 1)
|
||||
assert receiver.offer(encode_fragment(original, 256), 2) is None
|
||||
|
||||
|
||||
def test_wrong_stream_and_wraparound() -> None:
|
||||
receiver = FrameReassembler(7, 256)
|
||||
other = encode_fragment(split_frame(replace(frame(size=1), stream_id=8), 256)[0], 256)
|
||||
assert receiver.offer(other, 0) is None
|
||||
assert is_newer(0, 0xFFFFFFFF)
|
||||
assert not is_newer(0xFFFFFFFE, 1)
|
||||
|
||||
|
||||
def test_expired_frame_cannot_replay_after_closed_cache_rollover() -> None:
|
||||
receiver = FrameReassembler(7, 256, max_age_ms=10)
|
||||
for frame_id in range(1025):
|
||||
part = split_frame(frame(frame_id, frame_id * 20, 300), 256)[0]
|
||||
assert receiver.offer(encode_fragment(part, 256), frame_id * 20) is None
|
||||
receiver.expire(frame_id * 20 + 10)
|
||||
assert 0 not in receiver._closed
|
||||
old_parts = [encode_fragment(part, 256) for part in split_frame(frame(0, 0, 300), 256)]
|
||||
assert receiver.offer(old_parts[0], 20_501) is None
|
||||
assert receiver.offer(old_parts[1], 20_502) is None
|
||||
assert receiver.rejected_fragments >= 2
|
||||
|
||||
|
||||
def test_two_new_frames_can_start_out_of_order() -> None:
|
||||
receiver = FrameReassembler(7, 256)
|
||||
newer_parts = [encode_fragment(item, 256) for item in split_frame(frame(11, 10, 300), 256)]
|
||||
older = encode_fragment(split_frame(frame(10, 0, 25), 256)[0], 256)
|
||||
assert receiver.offer(newer_parts[0], 20) is None
|
||||
assert receiver.offer(older, 21).frame_id == 10
|
||||
assert receiver.offer(newer_parts[1], 22).frame_id == 11
|
||||
|
||||
|
||||
def test_radio_formula_and_margin() -> None:
|
||||
current = estimate("BPSK", 500_000, 256, 5)
|
||||
assert current.coded_body_bits == 4300
|
||||
assert current.airtime_ms == pytest.approx(11.532)
|
||||
assert current.video_bytes_per_packet == 239
|
||||
assert current.useful_video_bps == pytest.approx(115_654.488)
|
||||
future = estimate("QPSK", 500_000, 1024, 0)
|
||||
assert not future.within_current_packet_limit
|
||||
assert not future.implemented_in_current_phy
|
||||
assert not margin_fits(future.useful_video_bps, 376_000, 0.2)
|
||||
63
tests/test_video003_design.py
Normal file
63
tests/test_video003_design.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Границы PHY, расчёт полезной скорости и отдельный компактный заголовок."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.video003_compact_header import HEADER_SIZE as COMPACT_SIZE
|
||||
from protocol.video003_compact_header import decode, encode
|
||||
from protocol.video_stream_transport import (
|
||||
HEADER_SIZE as CURRENT_SIZE, FrameType, VideoFragment, VideoTransportError,
|
||||
)
|
||||
from tools.video003_design import estimate, run
|
||||
|
||||
|
||||
def test_current_packet_limit_and_exact_max_video_fragment() -> None:
|
||||
result = run()
|
||||
assert result["max_full_phy_packet_bytes"] == 1024
|
||||
assert result["current_fixed_lab_generator_phy_payload_bytes"] == 256
|
||||
assert result["packet_header_bytes"] == 8
|
||||
assert result["packet_crc_bytes"] == 4
|
||||
assert result["video_header_bytes"] == 17
|
||||
assert result["max_format_phy_payload_bytes"] == 1012
|
||||
assert result["max_format_video_fragment_bytes"] == 995
|
||||
assert len(result["rows"]) == 7 * 4 * 5
|
||||
assert sorted(set(row["gap_ms"] for row in result["rows"])) == [0, 0.5, 1, 2, 5]
|
||||
with pytest.raises(ValueError, match="1024"):
|
||||
estimate("QPSK", 500_000, "3/4", 1013, 0)
|
||||
|
||||
|
||||
def test_maximum_does_not_reach_600k_even_without_video_header() -> None:
|
||||
result = run()
|
||||
best = result["best_calculated"]
|
||||
assert best["modulation"] == "QPSK"
|
||||
assert best["symbol_rate"] == 500_000
|
||||
assert best["fec_rate"] == "3/4"
|
||||
assert best["full_packet_bytes"] == 1024
|
||||
assert best["gap_ms"] == 0
|
||||
assert best["useful_video_rate_bps"] == pytest.approx(574_148.8747836122)
|
||||
assert best["useful_video_rate_bps"] < 600_000
|
||||
no_video_header = estimate("QPSK", 500_000, "3/4", 1012, 0, video_header_bytes=0)
|
||||
assert no_video_header.useful_video_rate_bps < 600_000
|
||||
|
||||
|
||||
def test_compact_header_roundtrip_preserves_identity_age_and_reassembly_fields() -> None:
|
||||
assert CURRENT_SIZE == 17 and COMPACT_SIZE == 13
|
||||
fragment = VideoFragment(0x56303032, 0xFFFFFFFE, 255, 256,
|
||||
FrameType.KEYFRAME, 299, b"video")
|
||||
wire = encode(fragment, 1012)
|
||||
assert len(wire) == COMPACT_SIZE + len(fragment.data)
|
||||
assert decode(wire, 1012) == fragment
|
||||
assert (CURRENT_SIZE - COMPACT_SIZE) / CURRENT_SIZE == pytest.approx(4 / 17)
|
||||
with pytest.raises(VideoTransportError):
|
||||
decode(bytes([0x12]) + wire[1:], 1012)
|
||||
with pytest.raises(VideoTransportError):
|
||||
decode(wire[:9], 1012)
|
||||
|
||||
|
||||
def test_compact_header_does_not_make_600k_reachable() -> None:
|
||||
row = estimate("QPSK", 500_000, "3/4", 1012, 0,
|
||||
video_header_bytes=COMPACT_SIZE)
|
||||
assert row.video_fragment_bytes == 999
|
||||
assert row.useful_video_rate_bps == pytest.approx(576_457.0109636468)
|
||||
assert row.useful_video_rate_bps < 600_000
|
||||
105
tests/test_video003_modem.py
Normal file
105
tests/test_video003_modem.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Эталонные векторы и строгий приём программного QPSK VIDEO-003."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from protocol import punctured_fec
|
||||
from protocol.video003_modem import (
|
||||
PREAMBLE_SYMBOLS,
|
||||
build_frame,
|
||||
known_preamble_bits,
|
||||
qpsk_soft,
|
||||
qpsk_symbols,
|
||||
receive_frame,
|
||||
rrc_roundtrip,
|
||||
samples_per_symbol,
|
||||
)
|
||||
|
||||
|
||||
def test_gray_mapping_known_vector_and_unit_energy() -> None:
|
||||
bits = np.array([0, 0, 0, 1, 1, 1, 1, 0], dtype=np.uint8)
|
||||
expected = np.array([-1-1j, -1+1j, 1+1j, 1-1j]) / math.sqrt(2)
|
||||
assert np.allclose(qpsk_symbols(bits), expected)
|
||||
assert np.allclose(np.abs(expected) ** 2, 1.0)
|
||||
assert np.array_equal((qpsk_soft(expected) >= 0).astype(np.uint8), bits)
|
||||
pairs = bits.reshape(4, 2)
|
||||
assert all(np.count_nonzero(pairs[index] != pairs[(index + 1) % 4]) == 1
|
||||
for index in range(4))
|
||||
|
||||
|
||||
def test_preamble_matches_lab043_reference() -> None:
|
||||
from experiments.lab043_pluto_to_rtlsdr import pilot_prbs7
|
||||
|
||||
assert np.array_equal(known_preamble_bits(), pilot_prbs7(PREAMBLE_SYMBOLS, 0x7F))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", ["1/2", "2/3", "3/4"])
|
||||
def test_puncturing_preserves_existing_code_and_decodes_without_noise(rate: str) -> None:
|
||||
random_generator = np.random.default_rng(3303)
|
||||
for size in (1, 13, 256, 1012):
|
||||
bits = random_generator.integers(0, 2, size * 8, dtype=np.uint8)
|
||||
coded = punctured_fec.encode(bits, rate)
|
||||
assert len(coded) == punctured_fec.coded_bit_count(len(bits), rate)
|
||||
restored = punctured_fec.decode_soft(2.0 * coded - 1.0, len(bits), rate)
|
||||
assert np.array_equal(restored, bits)
|
||||
assert punctured_fec.effective_rate(len(bits), rate) < float(rate[0]) / float(rate[2])
|
||||
|
||||
|
||||
def test_puncturing_patterns_and_erasure_locations() -> None:
|
||||
assert punctured_fec.PUNCTURE_PATTERNS == {
|
||||
"1/2": (1, 1), "2/3": (1, 1, 1, 0), "3/4": (1, 1, 1, 0, 0, 1)
|
||||
}
|
||||
values = np.arange(punctured_fec.coded_bit_count(8, "2/3"), dtype=float) + 1
|
||||
restored = punctured_fec.depuncture_soft(values, 8, "2/3")
|
||||
assert np.array_equal(restored[3::4], np.zeros(len(restored[3::4])))
|
||||
with pytest.raises(ValueError):
|
||||
punctured_fec.depuncture_soft(values[:-1], 8, "2/3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modulation,symbol_rate", [
|
||||
("BPSK", 500_000), ("QPSK", 250_000), ("QPSK", 500_000)
|
||||
])
|
||||
@pytest.mark.parametrize("rate", ["1/2", "2/3", "3/4"])
|
||||
def test_independent_receive_phase_quadrant_and_cfo(
|
||||
modulation: str, symbol_rate: int, rate: str
|
||||
) -> None:
|
||||
payload = bytes(range(48))
|
||||
frame = build_frame(payload, modulation, rate)
|
||||
indexes = np.arange(len(frame.symbols), dtype=float)
|
||||
phase = 0.23 + (math.pi / 2 if modulation == "QPSK" else 0.0)
|
||||
shifted = frame.symbols * np.exp(1j * (phase + 2 * math.pi * 73 * indexes / symbol_rate))
|
||||
prefix = np.full(9, 0.1 + 0.2j)
|
||||
outcome = receive_frame(np.concatenate((prefix, shifted)), modulation, rate, symbol_rate)
|
||||
assert outcome.preamble_found and outcome.phy_header_pass and outcome.crc_pass
|
||||
assert outcome.start_symbol == 9
|
||||
assert outcome.payload == payload
|
||||
assert outcome.frequency_offset_hz == pytest.approx(73, abs=0.1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("symbol_rate", [250_000, 500_000])
|
||||
def test_rrc_known_timing_preserves_qpsk_packet(symbol_rate: int) -> None:
|
||||
frame = build_frame(b"RRC check", "QPSK", "2/3")
|
||||
filtered = rrc_roundtrip(frame.symbols, symbol_rate)
|
||||
result = receive_frame(filtered, "QPSK", "2/3", symbol_rate)
|
||||
assert samples_per_symbol(symbol_rate) == 8_000_000 // symbol_rate
|
||||
assert result.crc_pass and result.payload == b"RRC check"
|
||||
|
||||
|
||||
def test_corruption_and_truncation_cannot_become_crc_pass() -> None:
|
||||
frame = build_frame(bytes(range(32)), "QPSK", "3/4")
|
||||
truncated = receive_frame(frame.symbols[:-20], "QPSK", "3/4", 500_000)
|
||||
assert truncated.preamble_found and truncated.phy_header_pass and not truncated.crc_pass
|
||||
broken = frame.symbols.copy()
|
||||
broken[PREAMBLE_SYMBOLS:PREAMBLE_SYMBOLS + 144] *= -1
|
||||
rejected = receive_frame(broken, "QPSK", "3/4", 500_000)
|
||||
assert rejected.preamble_found and not rejected.phy_header_pass and not rejected.crc_pass
|
||||
|
||||
|
||||
def test_full_packet_limit_is_enforced_before_modulation() -> None:
|
||||
assert len(build_frame(bytes(1012), "QPSK", "3/4").packet) == 1024
|
||||
with pytest.raises(ValueError, match="1024"):
|
||||
build_frame(bytes(1013), "QPSK", "3/4")
|
||||
20
tests/test_video003_stream.py
Normal file
20
tests/test_video003_stream.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Непрерывный программный поток не получает границы из данных передатчика."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from tools.video003_stream import run
|
||||
|
||||
|
||||
def test_two_full_packets_per_rate_decode_from_received_header() -> None:
|
||||
result = run(packet_count=2, seed=20260916)
|
||||
assert result["rf_tx"] == 0
|
||||
for item in result["results"]:
|
||||
assert item["attempted_packets"] == 2
|
||||
assert item["crc_pass_count"] == item["exact_packet_count"] == 2
|
||||
assert item["verified_video_fragment_count"] == 2
|
||||
assert item["offline_verified_video_bps"] == pytest.approx(
|
||||
item["calculated_zero_loss_video_bps"])
|
||||
assert item["records"][0]["start_symbol_relative"] == 11
|
||||
assert item["records"][1]["start_symbol_relative"] == 0
|
||||
62
tests/test_video003_transport.py
Normal file
62
tests/test_video003_transport.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Проверка временной модели VIDEO-003 на настоящих малых H.264 кадрах."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from protocol.video_stream_transport import FrameType, VideoFrame
|
||||
from tools.video002_simulation import simulate
|
||||
from tools.video_compare import new_h264_encoder
|
||||
from tools.video003_design import estimate
|
||||
|
||||
|
||||
def _frames() -> list[VideoFrame]:
|
||||
encoder, _ = new_h264_encoder(64, 48, 5, 160_000)
|
||||
output = []
|
||||
for index in range(10):
|
||||
pixels = np.zeros((48, 64, 3), dtype=np.uint8)
|
||||
pixels[:, :, 1] = index * 17
|
||||
pixels[8:20, 3 * index:3 * index + 12, 0] = 240
|
||||
raw = av.VideoFrame.from_ndarray(pixels, format="rgb24").reformat(format="yuv420p")
|
||||
raw.pts = index
|
||||
packets = encoder.encode(raw)
|
||||
assert len(packets) == 1
|
||||
packet = packets[0]
|
||||
output.append(VideoFrame(33, index,
|
||||
FrameType.KEYFRAME if packet.is_keyframe
|
||||
else FrameType.DELTA_FRAME,
|
||||
200 * index, bytes(packet)))
|
||||
assert not encoder.encode(None)
|
||||
return output
|
||||
|
||||
|
||||
def test_explicit_fec_airtime_controls_video_age_and_p95() -> None:
|
||||
frames = _frames()
|
||||
row = estimate("QPSK", 500_000, "2/3", 1012, 0)
|
||||
link = ("QPSK_500_R23", "QPSK", 500_000, 1012, 0)
|
||||
calculated = simulate(frames, 5, link, ("random", 0),
|
||||
airtime_override_ms=row.packet_airtime_ms)
|
||||
slower = simulate(frames, 5, link, ("random", 0),
|
||||
airtime_override_ms=row.packet_airtime_ms * 2)
|
||||
assert calculated["displayed_frames"] == 10
|
||||
assert slower["displayed_frames"] == 10
|
||||
assert calculated["p95_end_to_end_age_ms"] <= slower["p95_end_to_end_age_ms"]
|
||||
assert calculated["p95_end_to_end_age_ms"] <= calculated["max_end_to_end_age_ms"]
|
||||
assert calculated["no_old_frame_replay"] and slower["no_old_frame_replay"]
|
||||
with pytest.raises(ValueError):
|
||||
simulate(frames, 5, link, ("random", 0), airtime_override_ms=math.nan)
|
||||
|
||||
|
||||
def test_design_headroom_uses_actual_video001_peaks() -> None:
|
||||
from tools.video003_design import measured_video_profiles
|
||||
|
||||
profiles = measured_video_profiles()
|
||||
assert profiles["NORMAL"]["measured_peak_1s_bps"] == pytest.approx(375_776, abs=200)
|
||||
assert profiles["HIGH_QUALITY"]["measured_peak_1s_bps"] == pytest.approx(455_600, abs=200)
|
||||
for profile in profiles.values():
|
||||
assert profile["required_20pct_bps"] == pytest.approx(
|
||||
profile["measured_peak_1s_bps"] * 1.2)
|
||||
85
tests/test_video004_erasure.py
Normal file
85
tests/test_video004_erasure.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Восстановление целых видеопакетов и срок жизни фрагментов VIDEO-004."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from protocol.packet_erasure_fec import InsufficientSymbolsError
|
||||
from protocol.video_stream_transport import FrameType, VideoFragment
|
||||
from protocol.video004_erasure import (
|
||||
MAX_VIDEO_BYTES, VideoErasureCollector, VideoErasureError,
|
||||
encode_block, parse_symbol, recover_block,
|
||||
)
|
||||
|
||||
|
||||
def _fragments(count: int, size: int = MAX_VIDEO_BYTES) -> tuple[tuple[VideoFragment, int], ...]:
|
||||
return tuple((VideoFragment(19, index // 4, index % 4, 4,
|
||||
FrameType.KEYFRAME if index < 4 else FrameType.DELTA_FRAME,
|
||||
0, bytes([index]) * (size if index != count - 1 else 7)),
|
||||
(index // 4) * 100) for index in range(count))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_count,parity_count", [
|
||||
(8, 1), (8, 2), (12, 2), (12, 3), (16, 2), (16, 4),
|
||||
])
|
||||
def test_recover_exact_source_from_one_and_up_to_r_losses(
|
||||
source_count: int, parity_count: int,
|
||||
) -> None:
|
||||
fragments = _fragments(source_count)
|
||||
wires = encode_block(fragments, 27, parity_count)
|
||||
assert len(wires) == source_count + parity_count
|
||||
assert all(len(wire) == 1012 for wire in wires)
|
||||
assert all(parse_symbol(wire).block_id == 27 for wire in wires)
|
||||
expected = recover_block(wires[:source_count])
|
||||
assert [item.fragment for item in expected] == [item[0] for item in fragments]
|
||||
assert [item.capture_ms for item in expected] == [item[1] for item in fragments]
|
||||
for erased_count in (1, parity_count):
|
||||
erased = set(random.Random(source_count * 100 + erased_count).sample(
|
||||
range(source_count), erased_count))
|
||||
available = tuple(wire for index, wire in enumerate(wires) if index not in erased)
|
||||
recovered = recover_block(available)
|
||||
assert [item.raw_video_wire for item in recovered] == [
|
||||
item.raw_video_wire for item in expected]
|
||||
assert {item.index for item in recovered if item.recovered} == erased
|
||||
with pytest.raises(InsufficientSymbolsError):
|
||||
recover_block(wires[parity_count + 1:])
|
||||
|
||||
|
||||
def test_collector_releases_systematic_immediately_and_rejects_late_recovery() -> None:
|
||||
fragments = _fragments(8)
|
||||
wires = encode_block(fragments, 5, 2)
|
||||
collector = VideoErasureCollector(19)
|
||||
first = collector.offer(wires[0], 15)
|
||||
assert len(first) == 1 and first[0].index == 0 and not first[0].recovered
|
||||
for index, wire in enumerate(wires[1:8], start=1):
|
||||
collector.offer(wire, 20 if index < 4 else 120)
|
||||
assert collector.parity_recoveries == 0
|
||||
missing = VideoErasureCollector(19)
|
||||
for index, wire in enumerate(wires[1:8], start=1):
|
||||
missing.offer(wire, 20 if index < 4 else 120)
|
||||
recovered = missing.offer(wires[8], 301)
|
||||
assert not recovered
|
||||
assert missing.stale_rejections >= 1
|
||||
timely = VideoErasureCollector(19)
|
||||
for index, wire in enumerate(wires[1:8], start=1):
|
||||
timely.offer(wire, 20 if index < 4 else 120)
|
||||
recovered = timely.offer(wires[8], 250)
|
||||
assert [item.index for item in recovered] == [0]
|
||||
assert timely.parity_recoveries == 1
|
||||
|
||||
|
||||
def test_invalid_metadata_and_mixed_stream_are_rejected() -> None:
|
||||
fragments = _fragments(8)
|
||||
wire = encode_block(fragments, 2, 1)[0]
|
||||
broken = bytearray(wire)
|
||||
broken[0] ^= 0xFF
|
||||
with pytest.raises(VideoErasureError):
|
||||
parse_symbol(bytes(broken))
|
||||
other = VideoErasureCollector(20)
|
||||
with pytest.raises(VideoErasureError):
|
||||
other.offer(wire, 0)
|
||||
collector = VideoErasureCollector(19)
|
||||
with pytest.raises(VideoErasureError, match="будущим"):
|
||||
collector.offer(encode_block(_fragments(8), 3, 1)[4], 10)
|
||||
25
tests/test_video004_keyframe.py
Normal file
25
tests/test_video004_keyframe.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""Усиление опорного кадра проверяется двумя потерями в одном блоке."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tools.video004_transport import (
|
||||
BASELINE_160, BASELINE_320, CODEC_RESULTS, _frames, simulate,
|
||||
)
|
||||
|
||||
|
||||
def test_double_keyframe_loss_needs_two_parity_symbols() -> None:
|
||||
codec = json.loads(CODEC_RESULTS.read_text(encoding="utf-8"))
|
||||
baseline = json.loads(BASELINE_320.read_text(encoding="utf-8"))
|
||||
baseline_160 = json.loads(BASELINE_160.read_text(encoding="utf-8"))
|
||||
combined, _ = _frames("NORMAL", 640, 360, 10, baseline["windows"][0],
|
||||
codec, baseline_160)
|
||||
frames = [frame for frame in combined if frame.stream_id == 0x56303334]
|
||||
base = simulate(frames, 10, "2/3", 8, 1, ("keyframe_double", 2))
|
||||
boosted = simulate(frames, 10, "2/3", 8, 1, ("keyframe_double", 2),
|
||||
keyframe_boost=True)
|
||||
assert base["displayed_frames"] == 55
|
||||
assert base["unrecoverable_blocks"] == 1
|
||||
assert boosted["displayed_frames"] == 60
|
||||
assert boosted["parity_recoveries"] == 2
|
||||
24
tests/test_video004_pipeline.py
Normal file
24
tests/test_video004_pipeline.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Полный доступный путь символы → CRC → стирания → видеокадр."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("numba")
|
||||
|
||||
from tools.video004_pipeline_benchmark import MANIFEST, SIGNAL, decode
|
||||
|
||||
|
||||
def test_four_frames_exact_after_four_packet_erasures() -> None:
|
||||
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||
received = np.load(SIGNAL, allow_pickle=False)
|
||||
assert hashlib.sha256(received.tobytes()).hexdigest() == manifest["signal_sha256"]
|
||||
result = decode(received, "numba")
|
||||
assert result["packet_sha256"] == manifest["expected_packet_sha256"]
|
||||
assert result["frame_sha256"] == manifest["expected_frame_sha256"]
|
||||
assert result["parity_recoveries"] == 4
|
||||
assert result["unrecoverable_blocks"] == 0
|
||||
34
tests/test_video004_transport.py
Normal file
34
tests/test_video004_transport.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Детерминированность модели и запрет позднего видеофрагмента."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from protocol.video_stream_transport import FrameReassembler, FrameType, VideoFragment
|
||||
from tools.video004_transport import (
|
||||
BASELINE_160, BASELINE_320, CODEC_RESULTS, _frames, simulate,
|
||||
)
|
||||
|
||||
|
||||
def test_v2_fragment_can_enter_reassembler_without_reserialization() -> None:
|
||||
receiver = FrameReassembler(19, 1012, max_age_ms=300)
|
||||
fragment = VideoFragment(19, 0, 0, 1, FrameType.KEYFRAME, 10, b"h264")
|
||||
completed = receiver.offer_fragment(fragment, 10)
|
||||
assert completed is not None and completed.data == b"h264"
|
||||
assert receiver.offer_fragment(fragment, 11) is None
|
||||
|
||||
|
||||
def test_loss_model_repeats_and_zero_loss_shows_all_normal_frames() -> None:
|
||||
codec = json.loads(CODEC_RESULTS.read_text(encoding="utf-8"))
|
||||
baseline = json.loads(BASELINE_320.read_text(encoding="utf-8"))
|
||||
baseline_160 = json.loads(BASELINE_160.read_text(encoding="utf-8"))
|
||||
combined, _ = _frames("NORMAL", 640, 360, 10, baseline["windows"][0],
|
||||
codec, baseline_160)
|
||||
frames = [frame for frame in combined if frame.stream_id == 0x56303334]
|
||||
clean = simulate(frames, 10, "2/3", 8, 1, ("random", 0))
|
||||
first = simulate(frames, 10, "2/3", 8, 2, ("random", 5))
|
||||
second = simulate(frames, 10, "2/3", 8, 2, ("random", 5))
|
||||
assert clean["displayed_frames"] == clean["input_frames"] == 60
|
||||
assert first == second
|
||||
assert first["parity_recoveries"] > 0
|
||||
assert first["no_old_frame_replay"]
|
||||
34
tests/test_video004_viterbi.py
Normal file
34
tests/test_video004_viterbi.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Побитовая эквивалентность JIT и исходного Витерби."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("numba")
|
||||
|
||||
from protocol import punctured_fec
|
||||
from protocol.video004_viterbi import decode_soft
|
||||
from tools.video004_benchmark import load_fixture, decode_stream
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rate", ["1/2", "2/3", "3/4"])
|
||||
@pytest.mark.parametrize("byte_count", [13, 256, 1012])
|
||||
def test_jit_matches_legacy_under_noise(rate: str, byte_count: int) -> None:
|
||||
generator = np.random.default_rng(4004 + byte_count)
|
||||
bits = generator.integers(0, 2, byte_count * 8, dtype=np.uint8)
|
||||
coded = punctured_fec.encode(bits, rate)
|
||||
soft = 2.0 * coded.astype(np.float64) - 1.0
|
||||
soft += generator.normal(0, 0.8, len(soft))
|
||||
expected = punctured_fec.decode_soft(soft, len(bits), rate)
|
||||
actual = decode_soft(soft, len(bits), rate)
|
||||
assert np.array_equal(actual, expected)
|
||||
|
||||
|
||||
def test_canonical_qpsk_decisions_fec_crc_and_packets_are_equal() -> None:
|
||||
received, manifest = load_fixture()
|
||||
before = decode_stream(received, "legacy")
|
||||
after = decode_stream(received, "numba")
|
||||
assert before == after
|
||||
assert len(after) == 32
|
||||
assert [row["packet_sha256"] for row in after] == manifest["expected_packet_sha256"]
|
||||
48
tests/test_video_001_compare.py
Normal file
48
tests/test_video_001_compare.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Проверки измерителя VIDEO-001 без камеры, SDR и исходного ролика."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from tools.video_compare import fit_frame, h264_case, jpeg_case, peak_second, radio_screen
|
||||
|
||||
|
||||
def test_four_by_three_output_preserves_source_geometry() -> None:
|
||||
source = np.full((72, 128, 3), 128, dtype=np.uint8)
|
||||
result = fit_frame(source, 640, 480)
|
||||
assert result.shape == (480, 640, 3)
|
||||
assert np.all(result[:60] == 0)
|
||||
assert np.all(result[60:420] == 128)
|
||||
assert np.all(result[420:] == 0)
|
||||
|
||||
|
||||
def test_h264_restores_exact_decoding_at_next_reference_frame(tmp_path) -> None:
|
||||
frames = []
|
||||
for index in range(10):
|
||||
frame = np.zeros((96, 128, 3), dtype=np.uint8)
|
||||
frame[:, :, 1] = 30 + index * 12
|
||||
frame[30:60, index * 5:index * 5 + 30, 0] = 250
|
||||
frames.append(frame)
|
||||
result = h264_case(frames, 5, 160_000, tmp_path / "example")
|
||||
assert result["decoded_frame_count"] == 10
|
||||
assert result["unique_decoded_frames"] == 10
|
||||
assert len(result["keyframe_sizes_bytes"]) == 2
|
||||
assert result["loss_recovery"]["lost_reference"]["first_exact_frame"] == 5
|
||||
assert result["loss_recovery"]["lost_delta"]["first_exact_frame"] == 5
|
||||
|
||||
|
||||
def test_jpeg_restores_on_the_next_independent_frame(tmp_path) -> None:
|
||||
frames = [np.full((32, 32, 3), index * 25, dtype=np.uint8) for index in range(5)]
|
||||
result = jpeg_case(frames, 5, tmp_path / "jpeg")
|
||||
assert result["decoded_frame_count"] == 5
|
||||
assert result["loss_recovery"]["first_exact_frame"] == 1
|
||||
|
||||
|
||||
def test_packetization_and_one_second_peak_do_not_use_mean_bitrate() -> None:
|
||||
assert peak_second([100, 100, 1000, 100, 100, 100], 2) == 8_800
|
||||
small = radio_screen([244] * 5, 5)
|
||||
assert small["peak_packet_slots_per_second"] == 5
|
||||
assert small["fits_current_radio_budget"] == "NOT_ESTABLISHED_NO_SUSTAINED_GOODPUT_TEST"
|
||||
large = radio_screen([10_000] * 5, 5)
|
||||
assert large["peak_packet_slots_per_second"] == 205
|
||||
assert large["fits_current_radio_budget"] == "NO_ON_OBSERVED_BURST_SCREEN"
|
||||
Reference in New Issue
Block a user