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

View File

@@ -0,0 +1,84 @@
"""Расчётная длительность пакета VIDEO-002; PHY и радиоприёмник не меняются."""
from __future__ import annotations
from dataclasses import asdict, dataclass
import math
from protocol.video_stream_transport import HEADER_SIZE
PROTOCOL_OVERHEAD_BYTES = 12 # Заголовок protocol.packet и CRC-32.
PREAMBLE_SYMBOLS = 1280
PHY_HEADER_SYMBOLS = 144
TRANSIENT_SYMBOLS = 42 # Два защитных интервала и хвост RRC.
FEC_TAIL_BITS = 6
RRC_ROLLOFF = 0.35
CURRENT_MAX_PACKET_BYTES = 1024
@dataclass(frozen=True)
class RadioEstimate:
modulation: str
symbols_per_second: int
phy_payload_bytes: int
gap_ms: float
video_bytes_per_packet: int
packet_bytes_before_fec: int
coded_body_bits: int
airtime_ms: float
useful_video_bps: float
overhead_fraction: float
raw_bps: int
occupied_bandwidth_hz: float
symbol_duration_us: float
within_current_packet_limit: bool
implemented_in_current_phy: bool
def as_dict(self) -> dict:
return asdict(self)
def estimate(modulation: str, symbols_per_second: int,
phy_payload_bytes: int, gap_ms: float) -> RadioEstimate:
"""QPSK: преамбула/PHY-заголовок BPSK, тело FEC по два бита на символ."""
if modulation not in ("BPSK", "QPSK") or symbols_per_second <= 0:
raise ValueError("Неизвестная модуляция или символьная скорость")
if phy_payload_bytes <= HEADER_SIZE or gap_ms < 0:
raise ValueError("Недопустимые нагрузка или пауза")
packet_bytes = phy_payload_bytes + PROTOCOL_OVERHEAD_BYTES
coded_bits = 2 * (packet_bytes * 8 + FEC_TAIL_BITS)
bits_per_symbol = 1 if modulation == "BPSK" else 2
body_symbols = math.ceil(coded_bits / bits_per_symbol)
symbols = PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_symbols + TRANSIENT_SYMBOLS
airtime_ms = 1000 * symbols / symbols_per_second
video_bytes = phy_payload_bytes - HEADER_SIZE
useful_bps = video_bytes * 8000 / (airtime_ms + gap_ms)
# Вся ёмкость интервала, кроме байтов изображения, относится к накладным расходам.
overhead = 1 - useful_bps / (symbols_per_second * bits_per_symbol)
return RadioEstimate(
modulation, symbols_per_second, phy_payload_bytes, gap_ms,
video_bytes, packet_bytes, coded_bits, airtime_ms, useful_bps,
overhead, symbols_per_second * bits_per_symbol,
symbols_per_second * (1 + RRC_ROLLOFF),
1_000_000 / symbols_per_second,
packet_bytes <= CURRENT_MAX_PACKET_BYTES,
modulation == "BPSK" and symbols_per_second == 500_000
and phy_payload_bytes == 256 and gap_ms == 5,
)
def matrix() -> list[RadioEstimate]:
return [estimate(modulation, speed, payload, gap)
for modulation, speed in (("BPSK", 500_000),
("QPSK", 250_000),
("QPSK", 500_000))
for payload in (256, 512, 1024)
for gap in (5, 2, 1, 0)]
def margin_fits(rate_bps: float, peak_bps: float, margin: float) -> bool:
"""Запас 20%: предельная скорость >= пик * 1.2."""
if not 0 <= margin <= 1:
raise ValueError("Запас вне диапазона 0–1")
return rate_bps >= peak_bps * (1 + margin)

View File

@@ -0,0 +1,289 @@
"""VIDEO-002: воспроизводимый файловый прогон H.264 через фрагменты и потери."""
from __future__ import annotations
import argparse
from fractions import Fraction
import hashlib
import json
import math
from pathlib import Path
import random
import av
from protocol.video_stream_transport import (
BoundedFrameQueue, FrameReassembler, FrameType, VideoFrame,
encode_fragment,
)
from tools.video002_radio_budget import estimate, matrix, margin_fits
from tools.video_compare import new_h264_encoder, source_frames
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "data/raw/video001/udacity_project_video.mp4"
RESULT_160 = ROOT / "data/processed/video001/comparison_traffic_160k.json"
RESULT_320 = ROOT / "data/processed/video001/comparison_traffic_320k.json"
DEFAULT_OUTPUT = ROOT / "data/processed/video002/results.json"
SEED = 20260916
SCENARIOS = tuple([("random", value) for value in (0, 1, 2, 5, 10, 20)]
+ [("burst", value) for value in (2, 5, 10)]
+ [("keyframe", 1), ("delta", 1)])
MODES = (("ROBUST", 320, 240, 5, 160_000),
("NORMAL", 640, 360, 10, 320_000),
("HIGH_QUALITY", 854, 480, 10, 320_000))
BUDGET_ONLY_MODES = (("640x480_10FPS", 640, 480, 10, 320_000),
("1280x720_5FPS", 1280, 720, 5, 320_000))
LINKS = (("CURRENT_BPSK_256_GAP5", "BPSK", 500_000, 256, 5),
("CALCULATED_QPSK_1024_GAP0", "QPSK", 500_000, 1024, 0),
("SOFTWARE_2MBPS", None, 2_000_000, 1024, 0))
def _result_case(results: dict, window: str, width: int, height: int,
fps: int) -> dict:
matches = [case for case in results["cases"]
if case["window"] == window and case["resolution"] == [width, height]
and case["fps"] == fps and case["codec"] == "H264_LIBX264"]
if len(matches) != 1:
raise ValueError("Не найдена единственная исходная серия VIDEO-001")
return matches[0]
def load_real_stream(source: Path, window: dict, width: int, height: int,
fps: int, maxrate_bps: int, expected_sizes: list[int]) -> list[VideoFrame]:
"""Повторить кодирование VIDEO-001 и сверить каждый размер с исходным JSON."""
frames = source_frames(source, width, height, fps,
window["start_seconds"], window["duration_seconds"])
encoder, _ = new_h264_encoder(width, height, fps, maxrate_bps)
encoded: list[VideoFrame] = []
for index, pixels in enumerate(frames):
frame = av.VideoFrame.from_ndarray(pixels, format="rgb24").reformat(format="yuv420p")
frame.pts = index
packets = encoder.encode(frame)
if len(packets) != 1:
raise ValueError("Кодировщик не дал один пакет на кадр")
packet = packets[0]
encoded.append(VideoFrame(0x56303032, index,
FrameType.KEYFRAME if packet.is_keyframe
else FrameType.DELTA_FRAME,
round(index * 1000 / fps), bytes(packet)))
if encoder.encode(None):
raise ValueError("Неожиданные отложенные пакеты кодировщика")
actual_sizes = [len(frame.data) for frame in encoded]
if actual_sizes != expected_sizes:
raise ValueError("Поток не совпал с VIDEO-001 по размерам кадров")
return encoded
def _drop(kind: str, level: int, packet_index: int, frame_id: int,
fragment_index: int, fps: int, random_generator: random.Random,
burst_start: int | None) -> bool:
if kind == "random":
return random_generator.random() < level / 100
if kind == "burst":
return burst_start is not None and burst_start <= packet_index < burst_start + level
target_id = fps if kind == "keyframe" else fps + 1
return frame_id == target_id and fragment_index == 0
def simulate(frames: list[VideoFrame], fps: int, link: tuple,
scenario: tuple[str, int], seed: int = SEED,
max_age_ms: int = 300,
airtime_override_ms: float | None = None) -> dict:
"""Дискретные моменты захвата и завершения пакетов, затем настоящий H.264 decode."""
link_name, modulation, speed, payload, gap = link
kind, level = scenario
if airtime_override_ms is not None:
if not math.isfinite(airtime_override_ms) or airtime_override_ms <= 0:
raise ValueError("эфирное время должно быть положительным")
airtime_ms = airtime_override_ms
elif modulation:
airtime_ms = estimate(modulation, speed, payload, gap).airtime_ms
else:
# Контрольная программная линия: 2 Мбит/с полезной PHY-нагрузки без RF.
airtime_ms = payload * 8000 / speed
queue = BoundedFrameQueue(payload, max_frames=2, max_age_ms=max_age_ms)
reassembler = FrameReassembler(frames[0].stream_id, payload,
max_pending_frames=2, max_age_ms=max_age_ms)
decoder = av.CodecContext.create("h264", "r")
decoder.open()
random_generator = random.Random(seed)
next_arrival = 0
next_ready = 0.0
packet_index = 0
burst_start: int | None = None
lost_packets = 0
lost_frame_ids: set[int] = set()
displayed: list[tuple[int, float, str]] = []
decoder_errors = 0
synchronized = False
last_displayed_id: int | None = None
until_ms = frames[-1].capture_ms + max_age_ms
while next_arrival < len(frames) or queue.depth:
arrival_ms = (frames[next_arrival].capture_ms
if next_arrival < len(frames) else math.inf)
if arrival_ms <= next_ready or not queue.depth:
if next_arrival >= len(frames):
break
queue.enqueue(frames[next_arrival], arrival_ms)
next_ready = max(next_ready, float(arrival_ms))
next_arrival += 1
continue
start_ms = next_ready
end_ms = start_ms + airtime_ms
fragment = queue.next_fragment(math.floor(start_ms), end_ms)
if fragment is None:
continue
wire = encode_fragment(fragment, payload)
if kind == "burst" and burst_start is None:
burst_start = packet_index
packet_lost = _drop(kind, level, packet_index, fragment.frame_id,
fragment.fragment_index, fps, random_generator,
burst_start)
queue.mark_sent()
packet_index += 1
next_ready = end_ms + gap
if packet_lost:
lost_packets += 1
lost_frame_ids.add(fragment.frame_id)
continue
complete = reassembler.offer(wire, math.ceil(end_ms))
if complete is None:
continue
if complete.frame_type == FrameType.KEYFRAME:
decoder = av.CodecContext.create("h264", "r")
decoder.open()
synchronized = True
elif last_displayed_id is None or complete.frame_id != last_displayed_id + 1:
synchronized = False
if not synchronized:
continue
encoded_packet = av.Packet(complete.data)
encoded_packet.pts = encoded_packet.dts = complete.frame_id
encoded_packet.time_base = Fraction(1, fps)
try:
decoded = decoder.decode(encoded_packet)
except av.error.FFmpegError:
decoder_errors += 1
synchronized = False
continue
if len(decoded) != 1 or int(decoded[0].pts) != complete.frame_id:
decoder_errors += 1
synchronized = False
continue
digest = hashlib.sha256(decoded[0].to_ndarray(format="rgb24").tobytes()).hexdigest()
if last_displayed_id is not None and complete.frame_id <= last_displayed_id:
raise AssertionError("Повторный показ старого кадра")
displayed.append((complete.frame_id, end_ms - frames[complete.frame_id].capture_ms,
digest))
last_displayed_id = complete.frame_id
reassembler.expire(math.ceil(until_ms + 1))
displayed_ids = {item[0] for item in displayed}
if len(displayed_ids) != len(displayed):
raise AssertionError("Повторный номер показанного кадра")
lost_ids = sorted(lost_frame_ids)
recovery_ms = None
recovered_keyframe = None
if lost_ids:
first_lost = lost_ids[0]
next_keys = [frame_id for frame_id, age, _ in displayed
if frame_id > first_lost
and frames[frame_id].frame_type == FrameType.KEYFRAME]
if next_keys:
recovered_keyframe = next_keys[0]
shown = next(item for item in displayed if item[0] == recovered_keyframe)
recovery_ms = shown[1] + frames[recovered_keyframe].capture_ms - frames[first_lost].capture_ms
ages = [item[1] for item in displayed]
p95_age = (sorted(ages)[math.ceil(0.95 * len(ages)) - 1]
if ages else None)
return {
"link": link_name, "scenario": kind, "level": level, "seed": seed,
"input_frames": len(frames), "packet_attempts": packet_index,
"lost_packets": lost_packets, "affected_frames": len(lost_frame_ids),
"displayed_frames": len(displayed),
"displayed_fps": len(displayed) / (len(frames) / fps),
"frame_loss_fraction": 1 - len(displayed) / len(frames),
"displayed_frame_ids": [item[0] for item in displayed],
"keyframe_sha256_by_id": {
str(frame_id): digest for frame_id, _, digest in displayed
if frames[frame_id].frame_type == FrameType.KEYFRAME},
"unique_decoded_images": len({item[2] for item in displayed}),
"no_old_frame_replay": len(displayed_ids) == len(displayed),
"mean_end_to_end_age_ms": sum(ages) / len(ages) if ages else None,
"p95_end_to_end_age_ms": p95_age,
"max_end_to_end_age_ms": max(ages) if ages else None,
"recovery_ms_after_first_lost_fragment": recovery_ms,
"recovered_keyframe_id": recovered_keyframe,
"max_queue_depth": queue.max_depth,
"max_oldest_frame_age_ms": queue.max_oldest_age_ms,
"dropped_stale_frames": queue.stale_frame_drops,
"dropped_capacity_frames": queue.capacity_frame_drops,
"incomplete_expired_frames": reassembler.expired_frames,
"decoder_errors": decoder_errors,
}
def run(source: Path = SOURCE) -> dict:
source_digest = hashlib.sha256(source.read_bytes()).hexdigest()
results_160 = json.loads(RESULT_160.read_text(encoding="utf-8"))
results_320 = json.loads(RESULT_320.read_text(encoding="utf-8"))
if source_digest != results_160["source_sha256"] or source_digest != results_320["source_sha256"]:
raise ValueError("SHA-256 ролика не совпадает с VIDEO-001")
cases = []
for mode, width, height, fps, maxrate in MODES:
source_results = results_160 if maxrate == 160_000 else results_320
for window in source_results["windows"]:
original = _result_case(source_results, window["name"], width, height, fps)
frames = load_real_stream(source, window, width, height, fps, maxrate,
original["frame_sizes_bytes"])
window_cases = []
for link in LINKS:
for scenario in SCENARIOS:
measured = simulate(frames, fps, link, scenario)
measured.update({"mode": mode, "window": window["name"],
"resolution": [width, height], "fps": fps,
"original_average_bitrate_bps": original["average_bitrate_bps"],
"original_peak_bitrate_bps": original["peak_one_second_bitrate_bps"]})
window_cases.append(measured)
reference = next(item for item in window_cases
if item["link"] == "SOFTWARE_2MBPS"
and item["scenario"] == "random" and item["level"] == 0)
for item in window_cases:
for frame_id, digest in item["keyframe_sha256_by_id"].items():
if digest != reference["keyframe_sha256_by_id"][frame_id]:
raise AssertionError("Опорный кадр после потери отличается от чистого декодирования")
cases.extend(window_cases)
peaks = {mode: max(_result_case(results_160 if rate == 160_000 else results_320,
window["name"], width, height, fps)["peak_one_second_bitrate_bps"]
for window in results_320["windows"])
for mode, width, height, fps, rate in MODES + BUDGET_ONLY_MODES}
budgets = []
for row in matrix():
item = row.as_dict()
item["fits_peak_with_20pct_margin"] = {
name: margin_fits(row.useful_video_bps, peak, 0.2)
for name, peak in peaks.items()}
item["fits_peak_with_50pct_margin"] = {
name: margin_fits(row.useful_video_bps, peak, 0.5)
for name, peak in peaks.items()}
budgets.append(item)
return {"source_sha256": source_digest, "seed": SEED,
"max_age_ms": 300, "sender_queue_frames": 2,
"receiver_pending_frames": 2, "peak_bitrate_bps": peaks,
"radio_estimates": budgets, "simulations": cases,
"rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=SOURCE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run(args.source)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-002: {len(result['simulations'])} прогонов; {args.output}")
if __name__ == "__main__":
main()

188
tools/video003_codec.py Normal file
View File

@@ -0,0 +1,188 @@
"""VIDEO-003: сравнение H.264 через PyAV/libx264 на исходнике VIDEO-001."""
from __future__ import annotations
import argparse
from fractions import Fraction
import hashlib
import json
import math
from pathlib import Path
import statistics
import av
import numpy as np
from tools.video_compare import recovery_after_drop, source_frames
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "data/raw/video001/udacity_project_video.mp4"
BASELINE = ROOT / "data/processed/video001/comparison_traffic_320k.json"
DEFAULT_OUTPUT = ROOT / "data/processed/video003/codec_results.json"
VIDEO_MODES = (("NORMAL", 640, 360, 10), ("HIGH_QUALITY", 854, 480, 10),
("720P5", 1280, 720, 5))
PROFILES = ("CURRENT", "CURRENT_VBV", "SHORT_GOP", "INTRA_REFRESH")
BITRATES = (300_000, 400_000, 500_000)
def _encoder(width: int, height: int, fps: int, profile: str,
target_bps: int | None) -> tuple[av.CodecContext, dict]:
if profile not in PROFILES:
raise ValueError("неизвестный профиль H.264")
if profile == "CURRENT" and target_bps is not None:
raise ValueError("текущий профиль использует только исходный VBV 320k")
if profile != "CURRENT" and target_bps not in BITRATES:
raise ValueError("для профиля VBV нужен target 300/400/500 кбит/с")
encoder = av.CodecContext.create("libx264", "w")
encoder.width, encoder.height = width, height
encoder.pix_fmt = "yuv420p"
encoder.time_base = Fraction(1, fps)
encoder.framerate = Fraction(fps, 1)
encoder.thread_count = 1
encoder.max_b_frames = 0
gop_size = math.ceil(fps / 2) if profile == "SHORT_GOP" else fps
encoder.gop_size = gop_size
if profile != "CURRENT":
encoder.bit_rate = target_bps
maxrate_bps = 320_000 if profile == "CURRENT" else target_bps
buffer_bps = maxrate_bps if profile == "CURRENT" else round(target_bps * 0.2)
intra_refresh = profile == "INTRA_REFRESH"
parameters = (
f"keyint={gop_size}:min-keyint={gop_size}:scenecut=0:open-gop=0:"
f"bframes=0:rc-lookahead=0:sync-lookahead=0:repeat-headers=1:"
f"vbv-maxrate={maxrate_bps // 1000}:vbv-bufsize={buffer_bps // 1000}:"
f"intra-refresh={int(intra_refresh)}"
)
options = {"preset": "veryfast", "tune": "zerolatency", "crf": "28",
"x264-params": parameters}
encoder.options = options.copy()
encoder.open()
settings = {
"encoder_api": f"PyAV {av.__version__}",
"ffmpeg_libraries": {name: ".".join(map(str, version))
for name, version in av.library_versions.items()},
"ffmpeg_cli_used": False,
"codec_name": "libx264", "resolution": [width, height], "frame_rate": fps,
"pixel_format": "yuv420p", "bitrate_target_bps": target_bps,
"max_bitrate_bps": maxrate_bps, "buffer_size_bits": buffer_bps,
"buffer_duration_s": buffer_bps / maxrate_bps,
"gop_size_frames": gop_size, "b_frame_count": 0,
"preset": options["preset"], "tune": options["tune"],
"profile_requested": None, "profile_actual": encoder.profile,
"intra_refresh_option": intra_refresh,
"time_base": f"1/{fps}", "codec_options": options,
"bitrate_property_after_open_bps": encoder.bit_rate,
}
return encoder, settings
def _peak_rate(frame_sizes: list[int], fps: int, window_ms: int) -> float:
"""Максимум битов по полуоткрытому временному окну длиной window_ms."""
frame_times = [index * 1000 / fps for index in range(len(frame_sizes))]
best_bits = 0
for start in frame_times:
bits = sum(size * 8 for time_ms, size in zip(frame_times, frame_sizes)
if start <= time_ms < start + window_ms - 1e-9)
best_bits = max(best_bits, bits)
return best_bits * 1000 / window_ms
def encode_case(frames: list[np.ndarray], fps: int, profile: str,
target_bps: int | None) -> tuple[dict, list[bytes]]:
height, width = frames[0].shape[:2]
encoder, settings = _encoder(width, height, fps, profile, target_bps)
packets: list[bytes] = []
keyframes: list[int] = []
for index, pixels in enumerate(frames):
frame = av.VideoFrame.from_ndarray(pixels, format="rgb24").reformat(format="yuv420p")
frame.pts = index
encoded = encoder.encode(frame)
if len(encoded) != 1:
raise ValueError("ожидался один немедленный H.264 packet на кадр")
if encoded[0].is_keyframe:
keyframes.append(index)
packets.append(bytes(encoded[0]))
if encoder.encode(None):
raise ValueError("кодировщик задержал пакеты вопреки low-latency режиму")
decoder = av.CodecContext.create("h264", "r")
decoder.open()
clean_hashes = {}
psnr_values = []
decoded_count = 0
for index, data in enumerate(packets):
packet = av.Packet(data)
packet.pts = packet.dts = index
packet.time_base = Fraction(1, fps)
decoded = decoder.decode(packet)
if len(decoded) != 1 or decoded[0].pts != index:
raise ValueError("чистое декодирование не дало кадр с ожидаемым номером")
pixels = decoded[0].to_ndarray(format="rgb24")
clean_hashes[index] = hashlib.sha256(pixels.tobytes()).hexdigest()
if index == 0:
settings["profile_actual"] = decoder.profile
error = float(np.mean((frames[index].astype(np.float32)
- pixels.astype(np.float32)) ** 2))
psnr_values.append(10 * math.log10(255 * 255 / max(error, 1e-12)))
decoded_count += 1
sizes = [len(packet) for packet in packets]
dropped_frame = min(fps + 1, len(packets) - 2)
recovery = recovery_after_drop(packets, dropped_frame, fps, clean_hashes)
recovered_at = recovery["first_exact_frame"]
result = {
"settings": settings,
"frame_count": len(packets), "decoded_frame_count": decoded_count,
"frame_sizes_bytes": sizes, "keyframe_positions": keyframes,
"actual_average_bitrate_bps": sum(sizes) * 8 * fps / len(sizes),
"peak_100ms_bitrate_bps": _peak_rate(sizes, fps, 100),
"peak_1s_bitrate_bps": _peak_rate(sizes, fps, 1000),
"max_frame_size_bytes": max(sizes),
"p95_frame_size_bytes": float(np.percentile(sizes, 95)),
"quality": {"mean_psnr_db": statistics.mean(psnr_values),
"min_psnr_db": min(psnr_values),
"operator_visual_review": "NOT_RUN"},
"loss_recovery": {"dropped_frame": dropped_frame,
"first_exact_frame": recovered_at,
"recovery_time_ms": ((recovered_at - dropped_frame) * 1000 / fps
if recovered_at is not None else None),
"decoder_errors": recovery["decoder_errors"]},
}
return result, packets
def run() -> dict:
baseline = json.loads(BASELINE.read_text(encoding="utf-8"))
digest = hashlib.sha256(SOURCE.read_bytes()).hexdigest()
if digest != baseline["source_sha256"]:
raise ValueError("исходное видео не совпадает с VIDEO-001")
results = []
for mode, width, height, fps in VIDEO_MODES:
for window in baseline["windows"]:
frames = source_frames(SOURCE, width, height, fps,
window["start_seconds"], window["duration_seconds"])
for profile in PROFILES:
targets = (None,) if profile == "CURRENT" else BITRATES
for target in targets:
item, _ = encode_case(frames, fps, profile, target)
item.update({"mode": mode, "window": window["name"],
"profile": profile, "target_bps": target})
results.append(item)
print(f"{mode}/{window['name']}/{profile}/{target}: "
f"{item['actual_average_bitrate_bps'] / 1000:.1f} кбит/с",
flush=True)
return {"source": str(SOURCE.relative_to(ROOT)), "source_sha256": digest,
"cases": results, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-003: {len(result['cases'])} H.264 прогонов; {args.output}")
if __name__ == "__main__":
main()

170
tools/video003_design.py Normal file
View File

@@ -0,0 +1,170 @@
"""Точный расчёт допустимых пакетов и радиобюджета VIDEO-003, без SDR."""
from __future__ import annotations
import argparse
from dataclasses import asdict, dataclass
import json
import math
from pathlib import Path
from protocol import punctured_fec
from protocol.packet import CRC_SIZE, HEADER_SIZE as PACKET_HEADER_SIZE
from protocol.video_stream_transport import HEADER_SIZE as VIDEO_HEADER_SIZE
from protocol.video003_modem import MAX_FULL_PACKET_BYTES, PREAMBLE_SYMBOLS, PHY_HEADER_SYMBOLS
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "data/processed/video003/design_space.json"
PHY_PAYLOADS = (256, 512, 768, MAX_FULL_PACKET_BYTES - PACKET_HEADER_SIZE - CRC_SIZE)
GAPS_MS = (5.0, 2.0, 1.0, 0.5, 0.0)
MODES = (("BPSK", 500_000, "1/2"),
("QPSK", 250_000, "1/2"), ("QPSK", 250_000, "2/3"),
("QPSK", 250_000, "3/4"), ("QPSK", 500_000, "1/2"),
("QPSK", 500_000, "2/3"), ("QPSK", 500_000, "3/4"))
TRANSIENT_SYMBOLS = 42
RRC_ROLLOFF = 0.35
@dataclass(frozen=True)
class DesignRow:
modulation: str
symbol_rate: int
fec_rate: str
phy_payload_bytes: int
video_fragment_bytes: int
full_packet_bytes: int
gap_ms: float
raw_bit_rate_bps: int
coded_useful_rate_bps: float
effective_fec_rate: float
coded_body_bits: int
packet_symbols: int
packet_airtime_ms: float
gap_airtime_ms: float
duty_cycle: float
useful_video_rate_bps: float
overhead_fraction: float
occupied_bandwidth_estimate_hz: float
samples_per_symbol_at_8msps: float
current_lab_generator_implemented: bool
format_valid: bool
def as_dict(self) -> dict:
return asdict(self)
def estimate(modulation: str, symbol_rate: int, fec_rate: str,
phy_payload_bytes: int, gap_ms: float,
video_header_bytes: int = VIDEO_HEADER_SIZE) -> DesignRow:
"""PHY payload содержит видеозаголовок; полный пакет содержит ещё 12 байт."""
if modulation not in ("BPSK", "QPSK") or symbol_rate <= 0:
raise ValueError("неверная модуляция или символьная скорость")
if fec_rate not in punctured_fec.PUNCTURE_PATTERNS:
raise ValueError("неверная скорость FEC")
if gap_ms < 0 or phy_payload_bytes <= video_header_bytes:
raise ValueError("неверная нагрузка или пауза")
packet_bytes = phy_payload_bytes + PACKET_HEADER_SIZE + CRC_SIZE
if packet_bytes > MAX_FULL_PACKET_BYTES:
raise ValueError("полный пакет превышает 1024 байта")
video_bytes = phy_payload_bytes - video_header_bytes
information_bits = packet_bytes * 8
coded_bits = punctured_fec.coded_bit_count(information_bits, fec_rate)
bits_per_symbol = 1 if modulation == "BPSK" else 2
symbols = (PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + TRANSIENT_SYMBOLS
+ math.ceil(coded_bits / bits_per_symbol))
airtime_ms = 1000 * symbols / symbol_rate
cycle_ms = airtime_ms + gap_ms
raw = symbol_rate * bits_per_symbol
useful = video_bytes * 8000 / cycle_ms
return DesignRow(
modulation, symbol_rate, fec_rate, phy_payload_bytes, video_bytes,
packet_bytes, gap_ms, raw,
raw * punctured_fec.effective_rate(information_bits, fec_rate),
punctured_fec.effective_rate(information_bits, fec_rate),
coded_bits, symbols, airtime_ms, gap_ms, airtime_ms / cycle_ms,
useful, 1 - useful / raw, symbol_rate * (1 + RRC_ROLLOFF),
8_000_000 / symbol_rate,
modulation == "BPSK" and symbol_rate == 500_000 and fec_rate == "1/2"
and phy_payload_bytes == 256 and gap_ms == 5,
True,
)
def measured_video_profiles() -> dict[str, dict]:
"""Взять максимум двух окон VIDEO-001 для каждого требуемого режима."""
output: dict[str, dict] = {}
sources = {
"ROBUST": ("comparison_traffic_160k.json", 320, 240, 5),
"NORMAL": ("comparison_traffic_320k.json", 640, 360, 10),
"HIGH_QUALITY": ("comparison_traffic_320k.json", 854, 480, 10),
"720P5": ("comparison_traffic_320k.json", 1280, 720, 5),
}
for name, (filename, width, height, fps) in sources.items():
source = ROOT / "data/processed/video001" / filename
document = json.loads(source.read_text(encoding="utf-8"))
cases = [case for case in document["cases"]
if case["resolution"] == [width, height] and case["fps"] == fps
and case["codec"] == "H264_LIBX264"]
if len(cases) != 2:
raise ValueError(f"ожидались два измеренных окна для {name}")
average = max(case["average_bitrate_bps"] for case in cases)
peak = max(case["peak_one_second_bitrate_bps"] for case in cases)
output[name] = {
"resolution": [width, height], "fps": fps,
"measured_average_bps": average, "measured_peak_1s_bps": peak,
"required_20pct_bps": 1.2 * peak,
"required_50pct_bps": 1.5 * peak,
"source_files": [str(source.relative_to(ROOT))],
}
return output
def run() -> dict:
profiles = measured_video_profiles()
rows = []
for modulation, symbol_rate, fec_rate in MODES:
for payload in PHY_PAYLOADS:
for gap_ms in GAPS_MS:
item = estimate(modulation, symbol_rate, fec_rate, payload, gap_ms).as_dict()
item["headroom_by_video_mode"] = {
name: item["useful_video_rate_bps"] / profile["measured_peak_1s_bps"]
for name, profile in profiles.items()
}
rows.append(item)
best = max(rows, key=lambda item: item["useful_video_rate_bps"])
minimum_rate = math.ceil(600_000 * best["packet_symbols"]
/ (best["video_fragment_bytes"] * 8))
return {
"max_full_phy_packet_bytes": MAX_FULL_PACKET_BYTES,
"current_fixed_lab_generator_phy_payload_bytes": 256,
"packet_header_bytes": PACKET_HEADER_SIZE,
"packet_crc_bytes": CRC_SIZE,
"video_header_bytes": VIDEO_HEADER_SIZE,
"max_format_phy_payload_bytes": PHY_PAYLOADS[-1],
"max_format_video_fragment_bytes": PHY_PAYLOADS[-1] - VIDEO_HEADER_SIZE,
"gap_ms": list(GAPS_MS),
"measured_video_profiles": profiles,
"rows": rows,
"best_calculated": best,
"minimum_symbol_rate_hz_for_600k_at_best_packet": minimum_rate,
"minimum_integer_sps_at_8msps_symbol_rate_hz": 8_000_000 / 15,
"rf_tx": 0,
"method": "exact puncturing length, ideal continuous packet schedule, zero loss",
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
best = result["best_calculated"]
print(f"VIDEO-003: {len(result['rows'])} строк, максимум "
f"{best['useful_video_rate_bps'] / 1000:.3f} кбит/с; {args.output}")
if __name__ == "__main__":
main()

134
tools/video003_stream.py Normal file
View File

@@ -0,0 +1,134 @@
"""Короткий непрерывный программный поток максимальных пакетов VIDEO-003."""
from __future__ import annotations
import argparse
import json
import math
from pathlib import Path
import time
import numpy as np
from protocol import punctured_fec
from protocol.video_stream_transport import (
FrameType, VideoFragment, decode_fragment, encode_fragment,
)
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame,
)
from tools.video003_design import ROOT, estimate
DEFAULT_OUTPUT = ROOT / "data/processed/video003/continuous_stream.json"
SEED = 20260916
PACKETS = 32
SYMBOL_RATE = 500_000
EBN0_DB = 9.0
CARRIER_OFFSET_HZ = 73.0
TRANSIENT_SYMBOLS = 42
VIDEO_BYTES = 995
PHY_PAYLOAD_BYTES = 1012
def run(packet_count: int = PACKETS, seed: int = SEED) -> dict:
if not 1 <= packet_count <= 256:
raise ValueError("число пакетов вне проверяемого диапазона")
generator = np.random.default_rng(seed)
records = []
for rate in ("2/3", "3/4"):
frames = []
for index in range(packet_count):
data = generator.integers(0, 256, VIDEO_BYTES, dtype=np.uint8).tobytes()
fragment = VideoFragment(0x56303033, index, 0, 1,
FrameType.KEYFRAME if index % 10 == 0
else FrameType.DELTA_FRAME, 0, data)
payload = encode_fragment(fragment, PHY_PAYLOAD_BYTES)
if len(payload) != PHY_PAYLOAD_BYTES:
raise AssertionError("неполный PHY payload")
frames.append(build_frame(payload, "QPSK", rate, sequence_number=index))
symbols = np.concatenate([part for frame in frames
for part in (frame.symbols,
np.zeros(TRANSIENT_SYMBOLS, dtype=np.complex128))])
body_symbols = math.ceil(punctured_fec.coded_bit_count(1024 * 8, rate) / 2)
n0 = (body_symbols / (1024 * 8)) / 10 ** (EBN0_DB / 10)
sigma = math.sqrt(n0 / 2)
phase = generator.uniform(-math.pi, math.pi) + math.pi / 2
indexes = np.arange(len(symbols), dtype=np.float64)
received = symbols * np.exp(1j * (phase + 2 * math.pi * CARRIER_OFFSET_HZ
* indexes / SYMBOL_RATE))
received += sigma * (generator.standard_normal(len(symbols))
+ 1j * generator.standard_normal(len(symbols)))
leading = sigma * (generator.standard_normal(11)
+ 1j * generator.standard_normal(11))
received = np.concatenate((leading, received))
cursor = 0
outcomes = []
started = time.perf_counter()
for index, expected in enumerate(frames):
outcome = receive_frame(received[cursor:], "QPSK", rate, SYMBOL_RATE)
fragment_pass = False
if outcome.crc_pass and outcome.payload is not None:
fragment = decode_fragment(outcome.payload, PHY_PAYLOAD_BYTES)
fragment_pass = fragment.frame_id == index and len(fragment.data) == VIDEO_BYTES
outcomes.append({
"packet_index": index, "preamble_found": outcome.preamble_found,
"phy_header_pass": outcome.phy_header_pass,
"crc_pass": outcome.crc_pass,
"packet_exact": outcome.packet == expected.packet,
"video_fragment_pass": fragment_pass,
"start_symbol_relative": outcome.start_symbol,
})
if not outcome.crc_pass or outcome.start_symbol is None:
break # Без заголовка/CRC нельзя знать следующий размер кадра.
received_packet_bytes = len(outcome.packet)
received_body_symbols = math.ceil(
punctured_fec.coded_bit_count(received_packet_bytes * 8, rate) / 2)
cursor += (outcome.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS
+ received_body_symbols + TRANSIENT_SYMBOLS)
processing_seconds = time.perf_counter() - started
signal_seconds = len(symbols) / SYMBOL_RATE
verified_fragments = sum(item["video_fragment_pass"] for item in outcomes)
budget = estimate("QPSK", SYMBOL_RATE, rate, PHY_PAYLOAD_BYTES, 0)
records.append({
"modulation": "QPSK", "symbol_rate": SYMBOL_RATE, "fec_rate": rate,
"phy_payload_bytes": PHY_PAYLOAD_BYTES, "full_packet_bytes": 1024,
"video_fragment_bytes": VIDEO_BYTES, "gap_ms": 0,
"ebn0_db": EBN0_DB, "n0_complex": n0,
"attempted_packets": packet_count, "decoded_packets": len(outcomes),
"crc_pass_count": sum(item["crc_pass"] for item in outcomes),
"exact_packet_count": sum(item["packet_exact"] for item in outcomes),
"verified_video_fragment_count": verified_fragments,
"observed_video_bits": verified_fragments * VIDEO_BYTES * 8,
"stream_symbols_excluding_leading_noise": len(symbols),
"signal_duration_s": signal_seconds,
"processing_duration_s": processing_seconds,
"processing_to_signal_ratio": processing_seconds / signal_seconds,
"offline_verified_video_bps": verified_fragments * VIDEO_BYTES * 8 / signal_seconds,
"calculated_zero_loss_video_bps": budget.useful_video_rate_bps,
"records": outcomes,
"receiver_truth_inputs": "modulation, FEC rate, symbol rate, known preamble and PHY format only",
})
return {"seed": seed, "packet_count": packet_count, "energy_condition":
"Es=1, Eb=Es*body_symbols/information_bits, includes QPSK padding but excludes fixed PHY overhead",
"scenario": "symbol-spaced AWGN, one continuous stream, 42 transient symbols, no inter-packet gap",
"results": records, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--packet-count", type=int, default=PACKETS)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run(args.packet_count)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
for item in result["results"]:
print(f"QPSK 500 / FEC {item['fec_rate']}: "
f"CRC {item['crc_pass_count']}/{item['attempted_packets']}, "
f"{item['offline_verified_video_bps'] / 1000:.3f} кбит/с, "
f"CPU/signal {item['processing_to_signal_ratio']:.2f}")
if __name__ == "__main__":
main()

147
tools/video003_synthetic.py Normal file
View File

@@ -0,0 +1,147 @@
"""Воспроизводимый AWGN-контроль BPSK/QPSK и проколотого FEC VIDEO-003."""
from __future__ import annotations
import argparse
from collections import defaultdict
import json
import math
from pathlib import Path
import numpy as np
from protocol import punctured_fec
from protocol.video003_modem import build_frame, receive_frame, rrc_roundtrip
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT = ROOT / "data/processed/video003/synthetic_phy.json"
MODES = (("BPSK", 500_000, "1/2"),
("QPSK", 250_000, "1/2"), ("QPSK", 250_000, "2/3"),
("QPSK", 250_000, "3/4"), ("QPSK", 500_000, "1/2"),
("QPSK", 500_000, "2/3"), ("QPSK", 500_000, "3/4"))
EBN0_DB = (0.0, 3.0, 6.0, 9.0)
TRIALS = 32
SEED = 20260916
PHY_PAYLOAD_BYTES = 256
CARRIER_OFFSET_HZ = 73.0
def _channel(symbols: np.ndarray, modulation: str, rate: str,
symbol_rate: int, ebn0_db: float, random_generator: np.random.Generator
) -> tuple[np.ndarray, float]:
"""Единичная энергия символа; Eb относится к информационным битам тела.
Фиксированные PHY overhead и пауза не входят в это определение Eb/N0.
Поэтому сравнение помехоустойчивости не подменяет расчёт goodput.
"""
bits_per_symbol = 1 if modulation == "BPSK" else 2
info_bits = (PHY_PAYLOAD_BYTES + 12) * 8
coded_bits = punctured_fec.coded_bit_count(info_bits, rate)
body_symbols = math.ceil(coded_bits / bits_per_symbol)
n0 = (body_symbols / info_bits) / 10 ** (ebn0_db / 10)
sigma = math.sqrt(n0 / 2)
phase = random_generator.uniform(-math.pi, math.pi)
if modulation == "QPSK":
phase += math.pi / 2
indexes = np.arange(len(symbols), dtype=np.float64)
rotated = symbols * np.exp(1j * (phase + 2 * math.pi * CARRIER_OFFSET_HZ
* indexes / symbol_rate))
noise = sigma * (random_generator.standard_normal(len(symbols))
+ 1j * random_generator.standard_normal(len(symbols)))
leading = sigma * (random_generator.standard_normal(11)
+ 1j * random_generator.standard_normal(11))
return np.concatenate((leading, rotated + noise)), n0
def run(trials: int = TRIALS, seed: int = SEED) -> dict:
if trials < 1:
raise ValueError("требуется хотя бы одна реализация")
random_generator = np.random.default_rng(seed)
records = []
for modulation, symbol_rate, rate in MODES:
for ebn0_db in EBN0_DB:
for trial in range(trials):
payload = random_generator.integers(
0, 256, PHY_PAYLOAD_BYTES, dtype=np.uint8).tobytes()
frame = build_frame(payload, modulation, rate)
received, n0 = _channel(frame.symbols, modulation, rate, symbol_rate,
ebn0_db, random_generator)
outcome = receive_frame(received, modulation, rate, symbol_rate)
pre_errors = pre_bits = post_errors = post_bits = None
if outcome.coded_soft is not None:
pre_errors = int(np.count_nonzero(
(outcome.coded_soft >= 0).astype(np.uint8) != frame.coded_bits))
pre_bits = len(frame.coded_bits)
if outcome.packet is not None:
recovered = np.unpackbits(np.frombuffer(outcome.packet, dtype=np.uint8))
original = np.unpackbits(np.frombuffer(frame.packet, dtype=np.uint8))
post_errors = int(np.count_nonzero(recovered != original))
post_bits = len(original)
records.append({
"modulation": modulation, "symbol_rate": symbol_rate,
"fec_rate": rate, "ebn0_db": ebn0_db, "trial": trial,
"n0_complex": n0, "preamble_found": outcome.preamble_found,
"phy_header_pass": outcome.phy_header_pass,
"fec_exact_packet": outcome.packet == frame.packet,
"crc_pass": outcome.crc_pass,
"pre_fec_bit_errors": pre_errors, "pre_fec_bits_compared": pre_bits,
"post_fec_bit_errors": post_errors, "post_fec_bits_compared": post_bits,
"estimated_cfo_hz": outcome.frequency_offset_hz,
})
groups: dict[tuple, list[dict]] = defaultdict(list)
for record in records:
groups[(record["modulation"], record["symbol_rate"],
record["fec_rate"], record["ebn0_db"])].append(record)
summaries = []
for key, items in groups.items():
pre_errors = sum(item["pre_fec_bit_errors"] or 0 for item in items)
pre_bits = sum(item["pre_fec_bits_compared"] or 0 for item in items)
post_errors = sum(item["post_fec_bit_errors"] or 0 for item in items)
post_bits = sum(item["post_fec_bits_compared"] or 0 for item in items)
summaries.append({
"modulation": key[0], "symbol_rate": key[1], "fec_rate": key[2],
"ebn0_db": key[3], "trials": len(items),
"preamble_found_count": sum(item["preamble_found"] for item in items),
"phy_header_pass_count": sum(item["phy_header_pass"] for item in items),
"fec_exact_packet_count": sum(item["fec_exact_packet"] for item in items),
"crc_pass_count": sum(item["crc_pass"] for item in items),
"pre_fec_ber_on_reached_bodies": pre_errors / pre_bits if pre_bits else None,
"pre_fec_bits_compared": pre_bits,
"post_fec_ber_on_decoded_packets": post_errors / post_bits if post_bits else None,
"post_fec_bits_compared": post_bits,
})
rrc = []
for symbol_rate in (250_000, 500_000):
frame = build_frame(bytes(range(PHY_PAYLOAD_BYTES)), "QPSK", "3/4")
shaped = rrc_roundtrip(frame.symbols, symbol_rate)
outcome = receive_frame(shaped, "QPSK", "3/4", symbol_rate)
rrc.append({"symbol_rate": symbol_rate, "samples_per_symbol": 8_000_000 // symbol_rate,
"packet_crc_pass": outcome.crc_pass,
"packet_exact": outcome.packet == frame.packet})
return {
"seed": seed, "trials_per_point": trials, "ebn0_db": list(EBN0_DB),
"packet_phy_payload_bytes": PHY_PAYLOAD_BYTES,
"carrier_offset_hz": CARRIER_OFFSET_HZ,
"energy_condition": "Es=1; Eb=Es*body_symbols/information_bits, includes QPSK padding but excludes fixed frame overhead",
"channel": "symbol-spaced complex AWGN plus fixed CFO and random phase; 11 leading noise symbols",
"pre_ber_scope": "only reached bodies, with denominator recorded",
"post_ber_scope": "only decoded packets, with denominator recorded",
"records": records, "summary": summaries, "rrc_controls": rrc, "rf_tx": 0,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--trials", type=int, default=TRIALS)
parser.add_argument("--seed", type=int, default=SEED)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run(args.trials, args.seed)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-003: {len(result['records'])} AWGN-реализаций; {args.output}")
if __name__ == "__main__":
main()

117
tools/video003_transport.py Normal file
View File

@@ -0,0 +1,117 @@
"""VIDEO-003: реальные H.264 кадры в модели очереди VIDEO-002 и нового PHY."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from protocol.video_stream_transport import FrameType, VideoFrame
from tools.video002_simulation import load_real_stream, simulate
from tools.video003_codec import BASELINE, SOURCE, encode_case, source_frames
from tools.video003_design import ROOT, estimate
DEFAULT_OUTPUT = ROOT / "data/processed/video003/transport_results.json"
CODEC_RESULTS = ROOT / "data/processed/video003/codec_results.json"
RESULT_160 = ROOT / "data/processed/video001/comparison_traffic_160k.json"
SCENARIOS = tuple(("random", loss) for loss in (0, 1, 2, 5, 10))
BURST_SCENARIOS = tuple(("burst", length) for length in (2, 5, 10))
LINKS = (
("CURRENT_BPSK_256_5MS", "BPSK", 500_000, "1/2", 256, 5.0),
("BPSK_500_R12_1012_0MS", "BPSK", 500_000, "1/2", 1012, 0.0),
("QPSK_250_R34_1012_0MS", "QPSK", 250_000, "3/4", 1012, 0.0),
("QPSK_500_R12_1012_0MS", "QPSK", 500_000, "1/2", 1012, 0.0),
("QPSK_500_R23_1012_0MS", "QPSK", 500_000, "2/3", 1012, 0.0),
("QPSK_500_R34_1012_0MS", "QPSK", 500_000, "3/4", 1012, 0.0),
("QPSK_500_R34_768_0P5MS", "QPSK", 500_000, "3/4", 768, 0.5),
)
def _frames(mode: str, width: int, height: int, fps: int,
window: dict, codec_results: dict, baseline_160: dict) -> tuple[list[VideoFrame], str]:
if mode == "ROBUST":
cases = [case for case in baseline_160["cases"]
if case["window"] == window["name"] and case["resolution"] == [width, height]
and case["fps"] == fps and case["codec"] == "H264_LIBX264"]
if len(cases) != 1:
raise ValueError("нет единственной исходной серии ROBUST")
return load_real_stream(SOURCE, window, width, height, fps, 160_000,
cases[0]["frame_sizes_bytes"]), "CURRENT_VIDEO001"
pixels = source_frames(SOURCE, width, height, fps,
window["start_seconds"], window["duration_seconds"])
frames: list[VideoFrame] = []
for profile, target in (("CURRENT", None), ("SHORT_GOP", 400_000)):
case = [item for item in codec_results["cases"]
if item["mode"] == mode and item["window"] == window["name"]
and item["profile"] == profile and item["target_bps"] == target]
if len(case) != 1:
raise ValueError("неоднозначный кодековый прогон")
result, packets = encode_case(pixels, fps, profile, target)
if (result["frame_sizes_bytes"] != case[0]["frame_sizes_bytes"]
or result["keyframe_positions"] != case[0]["keyframe_positions"]):
raise ValueError("повтор кодирования отличается от сохранённого результата")
keyframes = set(result["keyframe_positions"])
for index, packet in enumerate(packets):
frames.append(VideoFrame(
0x56303033 if profile == "CURRENT" else 0x56303334,
index, FrameType.KEYFRAME if index in keyframes else FrameType.DELTA_FRAME,
round(index * 1000 / fps), packet))
return frames, "CURRENT_AND_SHORT_GOP_400K"
def run() -> dict:
codec = json.loads(CODEC_RESULTS.read_text(encoding="utf-8"))
baseline_320 = json.loads(BASELINE.read_text(encoding="utf-8"))
baseline_160 = json.loads(RESULT_160.read_text(encoding="utf-8"))
if not (codec["source_sha256"] == baseline_320["source_sha256"]
== baseline_160["source_sha256"]):
raise ValueError("исходные ролики не совпадают")
modes = (("ROBUST", 320, 240, 5), ("NORMAL", 640, 360, 10),
("HIGH_QUALITY", 854, 480, 10), ("720P5", 1280, 720, 5))
results = []
for mode, width, height, fps in modes:
for window in baseline_320["windows"]:
combined, label = _frames(mode, width, height, fps, window, codec, baseline_160)
groups = [(label, combined)] if mode == "ROBUST" else [
("CURRENT", [frame for frame in combined if frame.stream_id == 0x56303033]),
("SHORT_GOP_400K", [frame for frame in combined if frame.stream_id == 0x56303334]),
]
for codec_profile, frames in groups:
for link_name, modulation, symbol_rate, fec_rate, payload, gap_ms in LINKS:
budget = estimate(modulation, symbol_rate, fec_rate, payload, gap_ms)
link = (link_name, modulation, symbol_rate, payload, gap_ms)
for scenario in SCENARIOS + BURST_SCENARIOS:
outcome = simulate(frames, fps, link, scenario,
airtime_override_ms=budget.packet_airtime_ms)
outcome.update({
"mode": mode, "codec_profile": codec_profile,
"window": window["name"], "resolution": [width, height],
"fps": fps, "modulation": modulation, "symbol_rate": symbol_rate,
"fec_rate": fec_rate, "phy_payload_bytes": payload,
"gap_ms": gap_ms, "calculated_useful_rate_bps": budget.useful_video_rate_bps,
})
results.append(outcome)
print(f"{mode}/{window['name']}: {len(results)} записей", flush=True)
return {
"source_sha256": codec["source_sha256"],
"random_loss_model": "independent Bernoulli per transmitted packet",
"burst_loss_model": "separate deterministic runs of 2/5/10 packet erasures",
"radio_model": "exact packet airtime from VIDEO-003 design table; full-packet conservative airtime",
"receiver": "VIDEO-002 bounded queue, max age 300 ms, actual H.264 decoder",
"cases": results, "rf_tx": 0,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-003: {len(result['cases'])} прогонов транспорта; {args.output}")
if __name__ == "__main__":
main()

200
tools/video004_benchmark.py Normal file
View File

@@ -0,0 +1,200 @@
"""Воспроизводимый замер доступного программного QPSK-приёмника без SDR."""
from __future__ import annotations
import argparse
import cProfile
import hashlib
import json
import math
import os
from pathlib import Path
import pstats
import sys
import time
import tracemalloc
import numpy as np
from protocol import punctured_fec
from protocol.video_stream_transport import FrameType, VideoFragment, decode_fragment, encode_fragment
from protocol.video003_modem import (
PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame,
)
from tools.video003_design import ROOT
RESULT_ROOT = ROOT / "data/processed/video004"
SIGNAL_PATH = RESULT_ROOT / "canonical_symbols.npy"
MANIFEST_PATH = RESULT_ROOT / "canonical_manifest.json"
SEED = 20260916
PACKETS = 32
RATE = "2/3"
SYMBOL_RATE = 500_000
TRANSIENT_SYMBOLS = 42
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def create_fixture(packet_count: int = PACKETS, signal_path: Path = SIGNAL_PATH,
manifest_path: Path = MANIFEST_PATH) -> dict:
"""Создать вход; эталонные пакеты сохраняются только для сверки после приёма."""
random_generator = np.random.default_rng(SEED)
frames = []
if not 1 <= packet_count <= 256:
raise ValueError("число пакетов вне диапазона")
for index in range(packet_count):
data = random_generator.integers(0, 256, 995, dtype=np.uint8).tobytes()
fragment = VideoFragment(0x56303033, index, 0, 1,
FrameType.KEYFRAME if index % 10 == 0 else FrameType.DELTA_FRAME,
0, data)
frames.append(build_frame(encode_fragment(fragment, 1012), "QPSK", RATE,
sequence_number=index))
symbols = np.concatenate([part for frame in frames for part in
(frame.symbols, np.zeros(TRANSIENT_SYMBOLS, dtype=np.complex128))])
body_symbols = math.ceil(punctured_fec.coded_bit_count(1024 * 8, RATE) / 2)
n0 = body_symbols / (1024 * 8) / 10 ** (9 / 10)
sigma = math.sqrt(n0 / 2)
phase = random_generator.uniform(-math.pi, math.pi) + math.pi / 2
indexes = np.arange(len(symbols), dtype=np.float64)
received = symbols * np.exp(1j * (phase + 2 * math.pi * 73 * indexes / SYMBOL_RATE))
received += sigma * (random_generator.standard_normal(len(symbols))
+ 1j * random_generator.standard_normal(len(symbols)))
leading = sigma * (random_generator.standard_normal(11)
+ 1j * random_generator.standard_normal(11))
received = np.concatenate((leading, received))
RESULT_ROOT.mkdir(parents=True, exist_ok=True)
np.save(signal_path, received, allow_pickle=False)
manifest = {
"seed": SEED, "packet_count": packet_count, "modulation": "QPSK",
"symbol_rate": SYMBOL_RATE, "fec_rate": RATE, "ebn0_db": 9,
"carrier_offset_hz": 73, "transient_symbols_per_packet": TRANSIENT_SYMBOLS,
"leading_noise_symbols": 11, "signal_duration_s": len(symbols) / SYMBOL_RATE,
"received_symbols": len(received), "dtype": str(received.dtype),
"signal_sha256": _sha(received.tobytes()),
"expected_packet_sha256": [_sha(frame.packet) for frame in frames],
"rf_tx": 0,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
return manifest
def load_fixture(signal_path: Path = SIGNAL_PATH,
manifest_path: Path = MANIFEST_PATH) -> tuple[np.ndarray, dict]:
received = np.load(signal_path, allow_pickle=False)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if (str(received.dtype) != manifest["dtype"]
or len(received) != manifest["received_symbols"]
or _sha(received.tobytes()) != manifest["signal_sha256"]):
raise ValueError("контрольный сигнал изменился")
return received, manifest
def decode_stream(received: np.ndarray, backend: str,
packet_count: int = PACKETS) -> list[dict]:
"""В приёмник не передаются границы, длины или эталонные пакеты."""
if backend == "legacy":
fec_decoder = None
elif backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
else:
raise ValueError("неизвестный декодер")
cursor = 0
outcomes = []
for _ in range(packet_count):
kwargs = {} if fec_decoder is None else {"fec_decoder": fec_decoder}
item = receive_frame(received[cursor:], "QPSK", RATE, SYMBOL_RATE, **kwargs)
if not item.crc_pass or item.packet is None or item.payload is None or item.start_symbol is None:
outcomes.append({"crc_pass": False, "start_symbol": item.start_symbol})
break
fragment = decode_fragment(item.payload, 1012)
hard_bits = (item.coded_soft >= 0).astype(np.uint8)
outcomes.append({
"crc_pass": True, "packet_sha256": _sha(item.packet),
"decoded_bits_sha256": _sha(np.unpackbits(np.frombuffer(item.packet, dtype=np.uint8)).tobytes()),
"qpsk_decisions_sha256": _sha(np.packbits(hard_bits).tobytes()),
"video_frame_id": fragment.frame_id, "video_data_sha256": _sha(fragment.data),
"start_symbol": item.start_symbol,
})
body_count = math.ceil(punctured_fec.coded_bit_count(len(item.packet) * 8, RATE) / 2)
cursor += item.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count + TRANSIENT_SYMBOLS
return outcomes
def run(phase: str, repetitions: int = 3) -> dict:
if phase not in ("before", "after") or repetitions < 1:
raise ValueError("неверный этап или число повторов")
if phase == "before" and not SIGNAL_PATH.exists():
create_fixture()
received, manifest = load_fixture()
backend = "legacy" if phase == "before" else "numba"
started = time.perf_counter()
first_outcomes = decode_stream(received, backend)
first_time = time.perf_counter() - started
times = []
for _ in range(repetitions):
started = time.perf_counter()
outcomes = decode_stream(received, backend)
times.append(time.perf_counter() - started)
if outcomes != first_outcomes:
raise AssertionError("повтор приёма изменил строгие выходы")
if len(outcomes) != PACKETS or not all(x["crc_pass"] for x in outcomes):
raise AssertionError("контрольный поток не принят полностью")
if [x["packet_sha256"] for x in outcomes] != manifest["expected_packet_sha256"]:
raise AssertionError("CRC-пакеты отличаются от эталона")
profiler = cProfile.Profile()
profiler.enable()
profiled_outcomes = decode_stream(received, backend)
profiler.disable()
if profiled_outcomes != outcomes:
raise AssertionError("профилировщик изменил строгие выходы")
stats = pstats.Stats(profiler).strip_dirs().sort_stats("cumtime")
listing = [{"file": key[0], "line": key[1], "function": key[2],
"primitive_calls": value[0], "calls": value[1],
"self_s": value[2], "cumulative_s": value[3]}
for key, value in stats.stats.items()]
listing.sort(key=lambda item: item["cumulative_s"], reverse=True)
tracemalloc.start()
decode_stream(received, backend)
_, python_peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
ordered = sorted(times)
median = ordered[len(ordered) // 2]
return {
"phase": phase, "backend": backend, "signal_sha256": manifest["signal_sha256"],
"signal_duration_s": manifest["signal_duration_s"],
"jit_first_run_s" if phase == "after" else "first_run_s": first_time,
"warm_run_times_s": times, "warm_median_s": median,
"available_path_realtime_factor": median / manifest["signal_duration_s"],
"profile_top": listing[:50], "profile_total_s": stats.total_tt,
"tracemalloc_python_peak_bytes": python_peak,
"tracemalloc_limit": "does not include all native NumPy/Numba allocations",
"outcomes": outcomes, "timing_recovery": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"carrier_tracking_from_raw_iq": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"rf_tx": 0,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--phase", choices=("before", "after"), required=True)
parser.add_argument("--repetitions", type=int, default=3)
parser.add_argument("--numba-packages", type=Path)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог Numba отсутствует")
sys.path.insert(0, str(args.numba_packages.resolve()))
os.environ["NUMBA_CACHE_DIR"] = str(RESULT_ROOT / "numba_cache")
(RESULT_ROOT / "numba_cache").mkdir(parents=True, exist_ok=True)
result = run(args.phase, args.repetitions)
output = RESULT_ROOT / f"benchmark_{args.phase}.json"
output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{args.phase}: first={result.get('jit_first_run_s', result.get('first_run_s')):.3f}s "
f"warm={result['warm_median_s']:.3f}s factor={result['available_path_realtime_factor']:.3f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,47 @@
"""Отдельный контроль задержки и серийных потерь при перемежении FEC-блоков."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from tools.video004_transport import (
BASELINE_160, BASELINE_320, CODEC_RESULTS, OUTPUT, _frames, simulate,
)
DEFAULT_OUTPUT = OUTPUT.with_name("interleaving_results.json")
def run() -> dict:
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"))
cases = []
for window in baseline["windows"]:
combined, _ = _frames("NORMAL", 640, 360, 10, window, codec, baseline_160)
frames = [frame for frame in combined if frame.stream_id == 0x56303334]
for k, r in ((8, 1), (8, 2), (12, 2)):
for scenario in (("random", 0), ("burst", 2), ("burst", 5), ("burst", 10)):
for depth in (1, 2):
result = simulate(frames, 10, "2/3", k, r,
scenario, interleave_depth=depth)
result["window"] = window["name"]
cases.append(result)
return {"source_sha256": codec["source_sha256"],
"interleaving_depths": [1, 2], "cases": cases, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-004: {len(result['cases'])} случаев перемежения; {args.output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,45 @@
"""Контроль одной и двух потерь источников опорного кадра H.264."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from tools.video004_transport import (
BASELINE_160, BASELINE_320, CODEC_RESULTS, OUTPUT, _frames, simulate,
)
DEFAULT_OUTPUT = OUTPUT.with_name("keyframe_recovery.json")
def run() -> dict:
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"))
cases = []
for window in baseline["windows"]:
combined, _ = _frames("NORMAL", 640, 360, 10, window, codec, baseline_160)
frames = [frame for frame in combined if frame.stream_id == 0x56303334]
for rate in ("2/3", "3/4"):
for k, r, boost in ((8, 1, False), (8, 1, True), (8, 2, False)):
for kind, count in (("keyframe", 1), ("keyframe_double", 2)):
item = simulate(frames, 10, rate, k, r, (kind, count), boost)
item["window"] = window["name"]
cases.append(item)
return {"source_sha256": codec["source_sha256"], "cases": cases, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-004: {len(result['cases'])} случаев потери опорного кадра; {args.output}")
if __name__ == "__main__":
main()

72
tools/video004_longrun.py Normal file
View File

@@ -0,0 +1,72 @@
"""Длинный контроль 256 пакетов после JIT-прогрева, без SDR."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import sys
import time
from tools.video004_benchmark import (
RESULT_ROOT, create_fixture, decode_stream, load_fixture,
)
SIGNAL = RESULT_ROOT / "canonical_symbols_256.npy"
MANIFEST = RESULT_ROOT / "canonical_manifest_256.json"
OUTPUT = RESULT_ROOT / "benchmark_256.json"
PACKETS = 256
def run() -> dict:
if not SIGNAL.exists():
create_fixture(PACKETS, SIGNAL, MANIFEST)
received, manifest = load_fixture(SIGNAL, MANIFEST)
if manifest["packet_count"] != PACKETS:
raise ValueError("неверное число пакетов длинного входа")
measured = {}
for backend, repeats in (("legacy", 1), ("numba", 3)):
times = []
outcomes = None
for _ in range(repeats):
started = time.perf_counter()
current = decode_stream(received, backend, PACKETS)
times.append(time.perf_counter() - started)
if outcomes is not None and current != outcomes:
raise AssertionError("длинный повтор изменил выход")
outcomes = current
if len(outcomes) != PACKETS or not all(item["crc_pass"] for item in outcomes):
raise AssertionError("длинный поток принят не полностью")
if [item["packet_sha256"] for item in outcomes] != manifest["expected_packet_sha256"]:
raise AssertionError("длинный поток отличается от эталона")
times.sort()
measured[backend] = {"times_s": times, "median_s": times[len(times) // 2],
"factor": times[len(times) // 2] / manifest["signal_duration_s"],
"outcomes": outcomes}
if measured["legacy"]["outcomes"] != measured["numba"]["outcomes"]:
raise AssertionError("длинный поток отличается до и после JIT")
for value in measured.values():
value.pop("outcomes")
return {"packet_count": PACKETS, "signal_sha256": manifest["signal_sha256"],
"signal_duration_s": manifest["signal_duration_s"],
"legacy": measured["legacy"], "numba": measured["numba"],
"strict_outputs_equal": True, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог Numba отсутствует")
sys.path.insert(0, str(args.numba_packages.resolve()))
result = run()
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"256 packets: legacy={result['legacy']['factor']:.3f} "
f"numba={result['numba']['factor']:.3f}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,178 @@
"""Замер от программных символов до видеокадра с внешним FEC, без SDR."""
from __future__ import annotations
import argparse
import cProfile
from dataclasses import replace
import hashlib
import json
import math
from pathlib import Path
import pstats
import sys
import time
import tracemalloc
import numpy as np
from protocol import punctured_fec
from protocol.video_stream_transport import FrameReassembler, FrameType, VideoFragment
from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, build_frame, receive_frame
from protocol.video004_erasure import MAX_VIDEO_BYTES, VideoErasureCollector, encode_block
from tools.video004_benchmark import RESULT_ROOT
SIGNAL = RESULT_ROOT / "pipeline_symbols.npy"
MANIFEST = RESULT_ROOT / "pipeline_manifest.json"
OUTPUT = RESULT_ROOT / "pipeline_benchmark.json"
PACKETS = 40
SYMBOL_RATE = 500_000
RATE = "2/3"
TRANSIENT = 42
def _sha(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def create_fixture() -> None:
generator = np.random.default_rng(4004)
frames = []
all_wires = []
for block_id in range(4):
payload = generator.integers(0, 256, MAX_VIDEO_BYTES * 8, dtype=np.uint8).tobytes()
frames.append(payload)
sources = tuple((VideoFragment(0x56303434, block_id, index, 8,
FrameType.KEYFRAME if block_id == 0 else FrameType.DELTA_FRAME,
0, payload[index * MAX_VIDEO_BYTES:(index + 1) * MAX_VIDEO_BYTES]),
math.floor(block_id * 152.3)) for index in range(8))
all_wires.extend(encode_block(sources, block_id, 2))
phy_frames = [build_frame(wire, "QPSK", RATE, sequence_number=index)
for index, wire in enumerate(all_wires)]
symbols = np.concatenate([part for frame in phy_frames
for part in (frame.symbols,
np.zeros(TRANSIENT, dtype=np.complex128))])
n0 = (math.ceil(punctured_fec.coded_bit_count(1024 * 8, RATE) / 2)
/ (1024 * 8) / 10 ** (9 / 10))
sigma = math.sqrt(n0 / 2)
indexes = np.arange(len(symbols), dtype=np.float64)
received = symbols * np.exp(1j * (0.4 + 2 * math.pi * 73 * indexes / SYMBOL_RATE))
received += sigma * (generator.standard_normal(len(symbols))
+ 1j * generator.standard_normal(len(symbols)))
received = np.concatenate((sigma * (generator.standard_normal(11)
+ 1j * generator.standard_normal(11)), received))
np.save(SIGNAL, received, allow_pickle=False)
MANIFEST.write_text(json.dumps({
"packet_count": PACKETS, "signal_duration_s": len(symbols) / SYMBOL_RATE,
"signal_sha256": _sha(received.tobytes()),
"expected_packet_sha256": [_sha(frame.packet) for frame in phy_frames],
"expected_frame_sha256": [_sha(frame) for frame in frames],
"erased_source_symbol_indices": [0, 10, 20, 30], "rf_tx": 0,
}, ensure_ascii=False, indent=2), encoding="utf-8")
def decode(received: np.ndarray, backend: str) -> dict:
fec_decoder = None
if backend == "numba":
from protocol.video004_viterbi import decode_soft as fec_decoder
elif backend != "legacy":
raise ValueError("неизвестный декодер")
collector = VideoErasureCollector(0x56303434)
reassembler = FrameReassembler(0x56303434, 1012, max_age_ms=300)
cursor = 0
packets = []
video = {}
for packet_index in range(PACKETS):
kwargs = {} if fec_decoder is None else {"fec_decoder": fec_decoder}
item = receive_frame(received[cursor:], "QPSK", RATE, SYMBOL_RATE, **kwargs)
if not item.crc_pass or item.packet is None or item.payload is None or item.start_symbol is None:
raise AssertionError(f"PHY/CRC отказ пакета {packet_index}")
packets.append(_sha(item.packet))
body_count = math.ceil(punctured_fec.coded_bit_count(len(item.packet) * 8, RATE) / 2)
end_symbol = cursor + item.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count
now_ms = math.ceil((end_symbol - 11) * 1000 / SYMBOL_RATE)
cursor = end_symbol + TRANSIENT
if packet_index in (0, 10, 20, 30):
continue # Контролируемое стирание только после успешного CRC.
for delivery in collector.offer(item.payload, now_ms):
age = now_ms - delivery.capture_ms
if age >= 300:
raise AssertionError("просроченный фрагмент прошёл FEC")
complete = reassembler.offer_fragment(replace(delivery.fragment, age_ms=age), now_ms)
if complete is not None:
video[complete.frame_id] = _sha(complete.data)
collector.finish()
return {"packet_sha256": packets,
"frame_sha256": [video[index] for index in sorted(video)],
"parity_recoveries": collector.parity_recoveries,
"unrecoverable_blocks": collector.unrecoverable_blocks}
def run(repetitions: int = 3) -> dict:
if not SIGNAL.exists():
create_fixture()
received = np.load(SIGNAL, allow_pickle=False)
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
if _sha(received.tobytes()) != manifest["signal_sha256"]:
raise AssertionError("сигнал полного доступного пути изменился")
measured = {}
expected = None
for backend in ("legacy", "numba"):
samples = []
for _ in range(repetitions):
started = time.perf_counter()
output = decode(received, backend)
samples.append(time.perf_counter() - started)
if expected is not None and output != expected:
raise AssertionError("выход полного доступного пути изменился")
expected = output
samples.sort()
measured[backend] = {"wall_times_s": samples, "median_s": samples[len(samples) // 2],
"realtime_factor": samples[len(samples) // 2] / manifest["signal_duration_s"]}
if (expected["packet_sha256"] != manifest["expected_packet_sha256"]
or expected["frame_sha256"] != manifest["expected_frame_sha256"]
or expected["parity_recoveries"] != 4 or expected["unrecoverable_blocks"]):
raise AssertionError("полный доступный путь не восстановил кадры точно")
profile = cProfile.Profile()
profile.enable()
decode(received, "numba")
profile.disable()
stats = pstats.Stats(profile).strip_dirs().sort_stats("cumtime")
listing = [{"function": key[2], "file": key[0], "calls": value[1],
"self_s": value[2], "cumulative_s": value[3]}
for key, value in stats.stats.items()]
listing.sort(key=lambda item: item["cumulative_s"], reverse=True)
tracemalloc.start()
decode(received, "numba")
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return {"packet_count": PACKETS, "signal_sha256": manifest["signal_sha256"],
"signal_duration_s": manifest["signal_duration_s"],
"legacy": measured["legacy"], "numba": measured["numba"],
"speedup": measured["legacy"]["median_s"] / measured["numba"]["median_s"],
"exact_packets_and_frames": True, "parity_recoveries": 4,
"profile_total_s": stats.total_tt, "profile_top": listing[:70],
"tracemalloc_python_peak_bytes": peak,
"timing_recovery": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"carrier_tracking_from_raw_iq": "NOT_IMPLEMENTED_IN_THIS_BENCHMARK",
"h264_decode": "NOT_INCLUDED; separately tested in transport model",
"rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог Numba отсутствует")
sys.path.insert(0, str(args.numba_packages.resolve()))
result = run()
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"40 packets + erasure FEC: legacy={result['legacy']['realtime_factor']:.3f} "
f"numba={result['numba']['realtime_factor']:.3f} recovered={result['parity_recoveries']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,51 @@
"""Повторные фиксированные затравки для выбора защиты NORMAL 640×360/10."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from tools.video004_transport import (
BASELINE_160, BASELINE_320, CODEC_RESULTS, OUTPUT, _frames, simulate,
)
DEFAULT_OUTPUT = OUTPUT.with_name("normal_seed_sweep.json")
SEEDS = tuple(range(20260916, 20260921))
VARIANTS = (("2/3", 8, 1, False), ("2/3", 8, 1, True),
("2/3", 8, 2, False), ("3/4", 8, 1, False),
("3/4", 8, 1, True), ("3/4", 8, 2, False))
def run() -> dict:
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"))
cases = []
for window in baseline["windows"]:
combined, _ = _frames("NORMAL", 640, 360, 10, window, codec, baseline_160)
frames = [frame for frame in combined if frame.stream_id == 0x56303334]
for rate, k, r, boost in VARIANTS:
for loss in (1, 5, 10):
for seed in SEEDS:
item = simulate(frames, 10, rate, k, r, ("random", loss),
keyframe_boost=boost, seed=seed)
item["window"] = window["name"]
cases.append(item)
return {"source_sha256": codec["source_sha256"], "seeds": list(SEEDS),
"cases": cases, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"NORMAL: {len(result['cases'])} случаев, {len(SEEDS)} затравок; {args.output}")
if __name__ == "__main__":
main()

278
tools/video004_transport.py Normal file
View File

@@ -0,0 +1,278 @@
"""Доставка настоящего H.264 через систематический внешний FEC без SDR."""
from __future__ import annotations
import argparse
from dataclasses import replace
from fractions import Fraction
import hashlib
import json
import math
from pathlib import Path
import random
import av
from protocol.video_stream_transport import (
FrameReassembler, FrameType, VideoFrame, VideoFragment,
)
from protocol.video004_erasure import (
MAX_AGE_MS, MAX_VIDEO_BYTES, VideoErasureCollector, decode_source,
encode_block, parse_symbol,
)
from tools.video003_design import ROOT, estimate
from tools.video003_transport import _frames
OUTPUT = ROOT / "data/processed/video004/transport_results.json"
CODEC_RESULTS = ROOT / "data/processed/video003/codec_results.json"
BASELINE_320 = ROOT / "data/processed/video001/comparison_traffic_320k.json"
BASELINE_160 = ROOT / "data/processed/video001/comparison_traffic_160k.json"
SEED = 20260916
SCHEMES = ((8, 1), (8, 2), (12, 2), (12, 3), (16, 2), (16, 4))
RANDOM_LOSSES = (0, 1, 2, 5, 10, 20)
BURSTS = (2, 5, 10)
def _source_fragments(frames: list[VideoFrame]) -> list[tuple[VideoFragment, int]]:
items = []
for frame in frames:
count = math.ceil(len(frame.data) / MAX_VIDEO_BYTES)
for index in range(count):
fragment = VideoFragment(
frame.stream_id, frame.frame_id, index, count, frame.frame_type, 0,
frame.data[index * MAX_VIDEO_BYTES:(index + 1) * MAX_VIDEO_BYTES],
)
items.append((fragment, frame.capture_ms))
return items
def _wire_schedule(frames: list[VideoFrame], source_count: int,
parity_count: int, keyframe_boost: bool,
interleave_depth: int) -> tuple[list[tuple[bytes, int, int, bool]], dict]:
if interleave_depth < 1:
raise ValueError("глубина перемежения должна быть положительной")
fragments = _source_fragments(frames)
blocks = []
boost_blocks = 0
partial_blocks = 0
for block_id, start in enumerate(range(0, len(fragments), source_count)):
sources = fragments[start:start + source_count]
partial_blocks += len(sources) < source_count
boost = keyframe_boost and any(item[0].frame_type == FrameType.KEYFRAME
for item in sources)
count = parity_count + int(boost)
boost_blocks += int(boost)
wires = encode_block(sources, block_id, count)
latest_capture = max(capture for _, capture in sources)
blocks.append([(wire, sources[index][1] if index < len(sources)
else latest_capture, block_id, index >= len(sources))
for index, wire in enumerate(wires)])
ordered = []
for first in range(0, len(blocks), interleave_depth):
group = blocks[first:first + interleave_depth]
for index in range(max(len(block) for block in group)):
for block in group:
if index < len(block):
ordered.append(block[index])
return ordered, {
"source_fragments": len(fragments), "fec_blocks": len(blocks),
"boosted_blocks": boost_blocks, "partial_blocks": partial_blocks,
}
def simulate(frames: list[VideoFrame], fps: int, rate: str,
source_count: int, parity_count: int, scenario: tuple[str, int],
keyframe_boost: bool = False, interleave_depth: int = 1,
seed: int = SEED) -> dict:
"""Модель по времени пакетов; систематические данные выдаются сразу."""
if not frames:
raise ValueError("пустой видеопоток")
schedule, block_stats = _wire_schedule(frames, source_count, parity_count,
keyframe_boost, interleave_depth)
collector = VideoErasureCollector(frames[0].stream_id)
reassembler = FrameReassembler(frames[0].stream_id, 1012,
max_pending_frames=2, max_age_ms=MAX_AGE_MS)
decoder = av.CodecContext.create("h264", "r")
decoder.open()
random_generator = random.Random(seed)
kind, level = scenario
airtime_ms = estimate("QPSK", 500_000, rate, 1012, 0).packet_airtime_ms
next_ready = 0.0
transmitted = 0
lost = 0
stale_tx_drops = 0
lost_frame_ids: set[int] = set()
displayed: list[tuple[int, float, str]] = []
decoder_errors = 0
synchronized = False
last_displayed_id: int | None = None
burst_start = max(0, len(schedule) // 3)
forced_key_targets: set[tuple[int, int]] = set()
if kind in ("keyframe", "keyframe_double"):
eligible: dict[int, list[int]] = {}
for candidate_wire, _, block_id, is_parity in schedule:
if is_parity:
continue
candidate = parse_symbol(candidate_wire)
fragment = decode_source(candidate.data, candidate.stream_id,
candidate.index, False).fragment
if fragment.frame_type == FrameType.KEYFRAME and fragment.frame_id >= fps:
eligible.setdefault(block_id, []).append(candidate.index)
target_count = 1 if kind == "keyframe" else 2
first_block = next((block_id for block_id, indices in eligible.items()
if len(indices) >= target_count), None)
if first_block is None:
raise ValueError("нет полного блока опорного кадра для заданной потери")
forced_key_targets = {(first_block, index)
for index in eligible[first_block][:target_count]}
for wire, ready_ms, block_id, is_parity in schedule:
start_ms = max(next_ready, float(ready_ms))
end_ms = start_ms + airtime_ms
symbol = parse_symbol(wire)
if not is_parity:
source = decode_source(symbol.data, symbol.stream_id, symbol.index, False)
if end_ms - source.capture_ms >= MAX_AGE_MS:
stale_tx_drops += 1
continue
next_ready = end_ms
if kind == "random":
packet_lost = random_generator.random() < level / 100
elif kind == "burst":
packet_lost = burst_start <= transmitted < burst_start + level
elif kind in ("keyframe", "keyframe_double"):
packet_lost = (block_id, symbol.index) in forced_key_targets
else:
raise ValueError("неизвестная модель потерь")
transmitted += 1
if packet_lost:
lost += 1
if not is_parity:
lost_frame_ids.add(source.fragment.frame_id)
continue
for delivery in collector.offer(wire, math.ceil(end_ms)):
age = math.ceil(end_ms - delivery.capture_ms)
if age >= MAX_AGE_MS:
raise AssertionError("просроченный FEC-фрагмент выдан приёмнику")
fragment = replace(delivery.fragment, age_ms=age)
complete = reassembler.offer_fragment(fragment, math.ceil(end_ms))
if complete is None:
continue
if complete.frame_type == FrameType.KEYFRAME:
decoder = av.CodecContext.create("h264", "r")
decoder.open()
synchronized = True
elif last_displayed_id is None or complete.frame_id != last_displayed_id + 1:
synchronized = False
if not synchronized:
continue
encoded = av.Packet(complete.data)
encoded.pts = encoded.dts = complete.frame_id
encoded.time_base = Fraction(1, fps)
try:
images = decoder.decode(encoded)
except av.error.FFmpegError:
decoder_errors += 1
synchronized = False
continue
if len(images) != 1 or int(images[0].pts) != complete.frame_id:
decoder_errors += 1
synchronized = False
continue
digest = hashlib.sha256(images[0].to_ndarray(format="rgb24").tobytes()).hexdigest()
if last_displayed_id is not None and complete.frame_id <= last_displayed_id:
raise AssertionError("повторный показ старого кадра")
displayed.append((complete.frame_id, end_ms - frames[complete.frame_id].capture_ms,
digest))
last_displayed_id = complete.frame_id
collector.finish()
reassembler.expire(frames[-1].capture_ms + MAX_AGE_MS + 1)
ages = sorted(item[1] for item in displayed)
shown_ids = {item[0] for item in displayed}
missing_ids = sorted(set(range(len(frames))) - shown_ids)
recovery_ms = None
if missing_ids:
first = missing_ids[0]
next_key = next((frame_id for frame_id, _, _ in displayed
if frame_id > first and frames[frame_id].frame_type == FrameType.KEYFRAME), None)
if next_key is not None:
shown = next(item for item in displayed if item[0] == next_key)
recovery_ms = frames[next_key].capture_ms + shown[1] - frames[first].capture_ms
capacity_rate = (MAX_VIDEO_BYTES * 8 / (airtime_ms / 1000)
* source_count / (source_count + parity_count))
return {
"fec_rate": rate, "data_count": source_count, "parity_count": parity_count,
"keyframe_boost": keyframe_boost, "interleave_depth": interleave_depth,
"scenario": kind, "level": level, "seed": seed,
"input_frames": len(frames), "displayed_frames": len(displayed),
"displayed_fps": len(displayed) / (len(frames) / fps),
"frame_loss_fraction": 1 - len(displayed) / len(frames),
"displayed_frame_ids": [item[0] for item in displayed],
"p95_frame_age_ms": ages[math.ceil(0.95 * len(ages)) - 1] if ages else None,
"max_frame_age_ms": ages[-1] if ages else None,
"recovery_ms": recovery_ms, "stale_drops": stale_tx_drops + collector.stale_rejections
+ reassembler.expired_frames + reassembler.rejected_fragments,
"stale_tx_drops": stale_tx_drops, "parity_recoveries": collector.parity_recoveries,
"unrecoverable_blocks": collector.unrecoverable_blocks,
"packet_attempts": transmitted, "lost_packets": lost,
"lost_source_frames": len(lost_frame_ids), "decoder_errors": decoder_errors,
"no_old_frame_replay": len(shown_ids) == len(displayed),
"raw_radio_useful_rate_bps": estimate("QPSK", 500_000, rate, 1012, 0).useful_video_rate_bps,
"video_header_bytes": 13, "outer_header_bytes": 16, "source_prefix_bytes": 6,
"max_net_video_payload_bytes": MAX_VIDEO_BYTES,
"net_capacity_bps_before_partial_blocks": capacity_rate,
"total_video_bits": sum(len(frame.data) * 8 for frame in frames),
"transmission_finish_ms": next_ready,
"effective_source_rate_bps": sum(len(frame.data) * 8 for frame in frames)
/ (next_ready / 1000) if next_ready else 0,
**block_stats,
}
def run() -> dict:
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"))
if not codec["source_sha256"] == baseline["source_sha256"] == baseline_160["source_sha256"]:
raise ValueError("несовпадение исходных роликов")
cases = []
for mode, width, height, fps in (("ROBUST", 320, 240, 5),
("NORMAL", 640, 360, 10),
("HIGH_QUALITY", 854, 480, 10)):
for window in baseline["windows"]:
frames, _ = _frames(mode, width, height, fps, window, codec, baseline_160)
if mode != "ROBUST":
frames = [frame for frame in frames if frame.stream_id == 0x56303334]
for rate in ("2/3", "3/4"):
for k, r in SCHEMES:
for boost in (False, True):
for scenario in ([('random', loss) for loss in RANDOM_LOSSES]
+ [('burst', length) for length in BURSTS]
+ [('keyframe', 1)]):
outcome = simulate(frames, fps, rate, k, r, scenario, boost)
outcome.update({"mode": mode, "window": window["name"],
"codec_profile": "ROBUST" if mode == "ROBUST"
else "SHORT_GOP_400K"})
cases.append(outcome)
print(f"{mode}/{window['name']}: {len(cases)} случаев", flush=True)
return {"source_sha256": codec["source_sha256"], "seed": SEED,
"random_loss_model": "independent Bernoulli per transmitted packet",
"burst_loss_model": "one fixed packet-index window near first third",
"keyframe_loss_model": "one source fragment of next keyframe",
"arrival_model": "source ready at capture; parity ready after all K captures",
"receiver": "systematic data immediately; recovered only before 300 ms; real H.264 decode",
"cases": cases, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=OUTPUT)
args = parser.parse_args()
result = run()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"VIDEO-004: {len(result['cases'])} программных случаев; {args.output}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,81 @@
"""Отдельный wall-clock замер Витерби на мягких значениях одного сигнала."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
from pathlib import Path
import sys
import time
import numpy as np
from protocol import punctured_fec
from protocol.video003_modem import PHY_HEADER_SYMBOLS, PREAMBLE_SYMBOLS, receive_frame
from tools.video004_benchmark import (
PACKETS, RATE, RESULT_ROOT, SYMBOL_RATE, TRANSIENT_SYMBOLS,
load_fixture,
)
OUTPUT = RESULT_ROOT / "viterbi_benchmark.json"
def run(repetitions: int = 3) -> dict:
if repetitions < 1:
raise ValueError("число повторов должно быть положительным")
from protocol.video004_viterbi import decode_soft as jit_decode
received, manifest = load_fixture()
cursor = 0
soft_words = []
for _ in range(PACKETS):
result = receive_frame(received[cursor:], "QPSK", RATE, SYMBOL_RATE,
fec_decoder=jit_decode)
if not result.crc_pass or result.coded_soft is None or result.packet is None:
raise AssertionError("контрольный поток не принят")
soft_words.append(result.coded_soft)
body_count = math.ceil(punctured_fec.coded_bit_count(len(result.packet) * 8, RATE) / 2)
cursor += result.start_symbol + PREAMBLE_SYMBOLS + PHY_HEADER_SYMBOLS + body_count + TRANSIENT_SYMBOLS
original = [punctured_fec.decode_soft(word, 1024 * 8, RATE) for word in soft_words]
optimized = [jit_decode(word, 1024 * 8, RATE) for word in soft_words]
if not all(np.array_equal(a, b) for a, b in zip(original, optimized)):
raise AssertionError("Витерби изменил биты")
times = {}
for name, decoder in (("before", punctured_fec.decode_soft), ("after", jit_decode)):
samples = []
for _ in range(repetitions):
started = time.perf_counter()
outputs = [decoder(word, 1024 * 8, RATE) for word in soft_words]
samples.append(time.perf_counter() - started)
if not all(np.array_equal(a, b) for a, b in zip(original, outputs)):
raise AssertionError("повтор Витерби изменил биты")
samples.sort()
times[name] = {"wall_times_s": samples, "median_s": samples[len(samples) // 2],
"realtime_factor": samples[len(samples) // 2] / manifest["signal_duration_s"]}
digest = hashlib.sha256(b"".join(word.tobytes() for word in soft_words)).hexdigest()
return {"signal_sha256": manifest["signal_sha256"], "soft_input_sha256": digest,
"packet_count": PACKETS, "signal_duration_s": manifest["signal_duration_s"],
"before": times["before"], "after": times["after"],
"speedup": times["before"]["median_s"] / times["after"]["median_s"],
"bits_equal": True, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
parser.add_argument("--repetitions", type=int, default=3)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог Numba отсутствует")
sys.path.insert(0, str(args.numba_packages.resolve()))
result = run(args.repetitions)
OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"Viterbi: before={result['before']['median_s']:.3f}s "
f"after={result['after']['median_s']:.3f}s speedup={result['speedup']:.1f}x")
if __name__ == "__main__":
main()

448
tools/video_compare.py Normal file
View File

@@ -0,0 +1,448 @@
"""VIDEO-001: JPEG и libx264 на одном сохранённом видеоролике, без SDR."""
from __future__ import annotations
import argparse
from fractions import Fraction
import hashlib
from io import BytesIO
import json
import math
import os
from pathlib import Path
import statistics
import time
import av
import cv2
import numpy as np
from PIL import Image
import psutil
MATRIX = ((320, 240, (5, 10, 15)), (640, 360, (5, 10, 15)),
(640, 480, (5, 10)), (854, 480, (5, 10)), (1280, 720, (5,)))
DEFAULT_WINDOWS = (("white_car", 7.0, 6.0), ("black_car", 33.0, 6.0))
PROCESS = psutil.Process(os.getpid())
FRAGMENT_BYTES = 244
SAMPLE_RATE = 8_000_000
PACKET_SAMPLES = 92_256
GAP_SAMPLES = 40_000
BURST_SECONDS = (16 * PACKET_SAMPLES + 15 * GAP_SAMPLES + 2 * 800_000) / SAMPLE_RATE
OBSERVED_VIDEO_EQUIVALENT_BPS = 10 * FRAGMENT_BYTES * 8 / BURST_SECONDS
def summary(values: list[float]) -> dict:
return {"mean": statistics.mean(values), "median": statistics.median(values),
"max": max(values)}
def peak_second(frame_sizes: list[int], fps: int) -> int:
return 8 * max(sum(frame_sizes[start:start + fps])
for start in range(len(frame_sizes) - fps + 1))
def radio_screen(frame_sizes: list[int], fps: int) -> dict:
packets = [math.ceil(size / FRAGMENT_BYTES) for size in frame_sizes]
peak_packets = max(sum(packets[start:start + fps])
for start in range(len(packets) - fps + 1))
observed_packets_per_second = 10 / BURST_SECONDS
zero_loss_packets_per_second = SAMPLE_RATE / (PACKET_SAMPLES + GAP_SAMPLES)
return {
"peak_packet_slots_per_second": peak_packets,
"mean_packets_per_frame": statistics.mean(packets),
"max_packets_per_frame": max(packets),
"airtime_for_peak_second_s": peak_packets / zero_loss_packets_per_second,
"zero_loss_packet_slots_per_second_with_5ms_gaps": zero_loss_packets_per_second,
"observed_one_burst_accepted_packets_per_second": observed_packets_per_second,
"fits_current_radio_budget": (
"NO_ON_OBSERVED_BURST_SCREEN" if peak_packets > observed_packets_per_second
else "NOT_ESTABLISHED_NO_SUSTAINED_GOODPUT_TEST"),
}
def fit_frame(frame: np.ndarray, width: int, height: int) -> np.ndarray:
"""Уместить исходный кадр целиком, сохранив соотношение сторон."""
source_height, source_width = frame.shape[:2]
scale = min(width / source_width, height / source_height)
scaled_width = min(width, max(1, round(source_width * scale)))
scaled_height = min(height, max(1, round(source_height * scale)))
scaled = cv2.resize(frame, (scaled_width, scaled_height), interpolation=cv2.INTER_AREA)
canvas = np.zeros((height, width, 3), dtype=np.uint8)
left = (width - scaled_width) // 2
top = (height - scaled_height) // 2
canvas[top:top + scaled_height, left:left + scaled_width] = scaled
return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)
def source_frames(path: Path, width: int, height: int, fps: int,
start_seconds: float, duration_seconds: float) -> list[np.ndarray]:
capture = cv2.VideoCapture(str(path))
if not capture.isOpened():
raise ValueError(f"Не открывается ролик: {path}")
source_fps = capture.get(cv2.CAP_PROP_FPS)
count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
requested = [round((start_seconds + index / fps) * source_fps)
for index in range(round(duration_seconds * fps))]
if source_fps <= 0 or not requested or len(set(requested)) != len(requested) or requested[-1] >= count:
capture.release()
raise ValueError("Окно вне ролика или частота ролика слишком мала")
selected = set(requested)
found = {}
try:
for index in range(requested[-1] + 1):
success, frame = capture.read()
if not success:
raise ValueError(f"Не прочитан исходный кадр {index}")
if index in selected:
found[index] = fit_frame(frame, width, height)
finally:
capture.release()
return [found[index] for index in requested]
def quality(reference: np.ndarray, decoded: np.ndarray) -> tuple[float, float]:
difference = reference.astype(np.float32) - decoded.astype(np.float32)
error = float(np.mean(difference * difference))
psnr = 10 * math.log10(255 * 255 / error) if error else 99.0
return psnr, float(np.mean(np.abs(difference)))
def save_example(base: Path, reference: np.ndarray, decoded: np.ndarray) -> None:
base.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(reference).save(base.with_name(base.name + "_source.png"))
Image.fromarray(decoded).save(base.with_name(base.name + "_decoded.png"))
def jpeg_case(frames: list[np.ndarray], fps: int, example: Path) -> dict:
sizes, encodes, decodes, latencies, psnrs, errors, hashes = ([] for _ in range(7))
payloads: list[bytes] = []
peak_rss = PROCESS.memory_info().rss
for index, reference in enumerate(frames):
started = time.perf_counter()
buffer = BytesIO()
Image.fromarray(reference).save(buffer, format="JPEG", quality=35,
subsampling=2, optimize=False, progressive=False)
encoded = buffer.getvalue()
payloads.append(encoded)
encoded_at = time.perf_counter()
with Image.open(BytesIO(encoded)) as image:
decoded = np.asarray(image.convert("RGB")).copy()
decoded_at = time.perf_counter()
sizes.append(len(encoded))
encodes.append(encoded_at - started)
decodes.append(decoded_at - encoded_at)
latencies.append(decoded_at - started)
psnr, error = quality(reference, decoded)
psnrs.append(psnr)
errors.append(error)
hashes.append(hashlib.sha256(decoded.tobytes()).hexdigest())
peak_rss = max(peak_rss, PROCESS.memory_info().rss)
if index == min(fps, len(frames) - 1):
save_example(example, reference, decoded)
with Image.open(BytesIO(payloads[1])) as image:
recovered_after_drop = np.asarray(image.convert("RGB"))
recovery_exact = hashlib.sha256(recovered_after_drop.tobytes()).hexdigest() == hashes[1]
return {"codec": "JPEG_INDEPENDENT",
"settings": {"quality": 35, "subsampling": "4:2:0",
"optimize": False, "progressive": False},
"frame_sizes_bytes": sizes, "reference_frame_size_bytes": max(sizes),
"encode_time_per_frame_s": summary(encodes),
"decode_time_per_frame_s": summary(decodes),
"file_pipeline_latency_s": summary(latencies),
"actual_encoded_fps": len(frames) / sum(encodes),
"unique_decoded_frames": len(set(hashes)), "decoded_frame_count": len(hashes),
"psnr_db": summary(psnrs), "mean_absolute_error": summary(errors),
"peak_process_rss_bytes": peak_rss,
"loss_recovery": {"dropped_frame": 0,
"first_exact_frame": 1 if recovery_exact else None},
"operator_quality": "PENDING_VISUAL_REVIEW"}
def recovery_after_drop(packets: list[bytes], dropped: int, fps: int,
clean_hashes: dict[int, str]) -> dict:
decoder = av.CodecContext.create("h264", "r")
decoder.open()
exact_after_loss = []
decode_errors = 0
for index, payload in enumerate(packets):
if index == dropped:
continue
packet = av.Packet(payload)
packet.pts = packet.dts = index
packet.time_base = Fraction(1, fps)
try:
decoded_frames = decoder.decode(packet)
except av.error.FFmpegError:
decode_errors += 1
continue
for frame in decoded_frames:
if frame.pts is None or frame.pts <= dropped:
continue
digest = hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest()
if clean_hashes.get(frame.pts) == digest:
exact_after_loss.append(int(frame.pts))
return {"dropped_frame": dropped,
"first_exact_frame": min(exact_after_loss) if exact_after_loss else None,
"decoder_errors": decode_errors}
def new_h264_encoder(width: int, height: int, fps: int,
maxrate_bps: int) -> tuple[av.CodecContext, str]:
encoder = av.CodecContext.create("libx264", "w")
encoder.width, encoder.height = width, height
encoder.pix_fmt = "yuv420p"
encoder.time_base = Fraction(1, fps)
encoder.framerate = Fraction(fps, 1)
encoder.thread_count = 1
parameters = (f"keyint={fps}:min-keyint={fps}:scenecut=0:open-gop=0:"
f"bframes=0:rc-lookahead=0:sync-lookahead=0:repeat-headers=1:"
f"vbv-maxrate={maxrate_bps // 1000}:vbv-bufsize={maxrate_bps // 1000}")
encoder.options = {"preset": "veryfast", "tune": "zerolatency", "crf": "28",
"x264-params": parameters}
encoder.open()
return encoder, parameters
def h264_case(frames: list[np.ndarray], fps: int, maxrate_bps: int, example: Path) -> dict:
height, width, _ = frames[0].shape
encoder, parameters = new_h264_encoder(width, height, fps, maxrate_bps)
decoder = av.CodecContext.create("h264", "r")
decoder.open()
sizes = [0] * len(frames)
encodes, decodes, latencies, psnrs, errors = ([] for _ in range(5))
packets: list[tuple[int, bytes, bool]] = []
hashes: dict[int, str] = {}
submitted: dict[int, float] = {}
peak_rss = PROCESS.memory_info().rss
for index, reference in enumerate(frames):
started = time.perf_counter()
submitted[index] = started
frame = av.VideoFrame.from_ndarray(reference, format="rgb24").reformat(format="yuv420p")
frame.pts = index
encoded_packets = encoder.encode(frame)
encodes.append(time.perf_counter() - started)
decode_cost = 0.0
for encoded in encoded_packets:
packet_index = int(encoded.pts)
payload = bytes(encoded)
packets.append((packet_index, payload, bool(encoded.is_keyframe)))
sizes[packet_index] += len(payload)
packet = av.Packet(payload)
packet.pts = packet.dts = packet_index
packet.time_base = Fraction(1, fps)
decoding_started = time.perf_counter()
decoded_frames = decoder.decode(packet)
decode_cost += time.perf_counter() - decoding_started
for item in decoded_frames:
decoded_index = int(item.pts)
decoded = item.to_ndarray(format="rgb24")
latencies.append(time.perf_counter() - submitted[decoded_index])
hashes[decoded_index] = hashlib.sha256(decoded.tobytes()).hexdigest()
psnr, error = quality(frames[decoded_index], decoded)
psnrs.append(psnr)
errors.append(error)
if decoded_index == min(fps, len(frames) - 1):
save_example(example, frames[decoded_index], decoded)
decodes.append(decode_cost)
peak_rss = max(peak_rss, PROCESS.memory_info().rss)
if encoder.encode(None) or len(packets) != len(frames) or len(hashes) != len(frames):
raise RuntimeError("H.264 задержал или потерял кадры")
if sorted(index for index, _, _ in packets) != list(range(len(frames))):
raise RuntimeError("H.264 нарушил нумерацию кадров")
if any(key != (index % fps == 0) for index, _, key in packets):
raise RuntimeError("Период опорных кадров отличается от заданного")
reference_sizes = [len(payload) for _, payload, key in packets if key]
payloads = [payload for _, payload, _ in packets]
return {"codec": "H264_LIBX264",
"settings": {"container": "Annex B packet stream", "preset": "veryfast",
"tune": "zerolatency", "crf": 28, "pixel_format": "yuv420p",
"maxrate_bps": maxrate_bps, "vbv_buffer_bits": maxrate_bps,
"gop_frames": fps, "b_frames": 0, "encoder_threads": 1,
"x264_parameters": parameters,
"pyav_version": av.__version__,
"ffmpeg_libraries": {name: ".".join(map(str, version))
for name, version in av.library_versions.items()}},
"frame_sizes_bytes": sizes,
"keyframe_sizes_bytes": reference_sizes,
"reference_frame_size_bytes": max(reference_sizes),
"encode_time_per_frame_s": summary(encodes),
"decode_time_per_frame_s": summary(decodes),
"file_pipeline_latency_s": summary(latencies),
"actual_encoded_fps": len(frames) / sum(encodes),
"unique_decoded_frames": len(set(hashes.values())),
"decoded_frame_count": len(hashes),
"psnr_db": summary(psnrs), "mean_absolute_error": summary(errors),
"peak_process_rss_bytes": peak_rss,
"loss_recovery": {
"lost_reference": recovery_after_drop(payloads, 0, fps, hashes),
"lost_delta": recovery_after_drop(payloads, 1, fps, hashes)},
"operator_quality": "PENDING_VISUAL_REVIEW"}
def end_to_end_file_case(source: Path, frames: list[np.ndarray], fps: int,
start_seconds: float, width: int, height: int,
codec: str, maxrate_bps: int,
expected_sizes: list[int]) -> dict:
"""Замерить чтение очередного кадра MP4 до готового декодированного RGB."""
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise ValueError(f"Не открывается ролик: {source}")
source_fps = capture.get(cv2.CAP_PROP_FPS)
requested = [round((start_seconds + index / fps) * source_fps)
for index in range(len(frames))]
if not capture.set(cv2.CAP_PROP_POS_FRAMES, requested[0]):
capture.release()
raise RuntimeError("Не удалось перейти к началу измеряемого участка")
next_source_index = requested[0]
encoder = decoder = None
if codec == "H264_LIBX264":
encoder, _ = new_h264_encoder(width, height, fps, maxrate_bps)
decoder = av.CodecContext.create("h264", "r")
decoder.open()
latencies = []
source_costs = []
peak_rss = PROCESS.memory_info().rss
try:
for index, target in enumerate(requested):
started = time.perf_counter()
while next_source_index <= target:
success, source_frame = capture.read()
if not success:
raise RuntimeError(f"Не прочитан кадр MP4 {next_source_index}")
next_source_index += 1
reference = fit_frame(source_frame, width, height)
source_costs.append(time.perf_counter() - started)
if not np.array_equal(reference, frames[index]):
raise RuntimeError("Кадр прямого файлового прохода отличается от исходного")
if codec == "JPEG_INDEPENDENT":
buffer = BytesIO()
Image.fromarray(reference).save(buffer, format="JPEG", quality=35,
subsampling=2, optimize=False,
progressive=False)
encoded = buffer.getvalue()
with Image.open(BytesIO(encoded)) as image:
decoded = np.asarray(image.convert("RGB"))
else:
frame = av.VideoFrame.from_ndarray(reference, format="rgb24").reformat(
format="yuv420p")
frame.pts = index
packets = encoder.encode(frame)
if len(packets) != 1:
raise RuntimeError("Файловый H.264 проход задержал пакет")
encoded = bytes(packets[0])
packet = av.Packet(encoded)
packet.pts = packet.dts = index
packet.time_base = Fraction(1, fps)
images = decoder.decode(packet)
if len(images) != 1:
raise RuntimeError("Файловый H.264 проход задержал кадр")
decoded = images[0].to_ndarray(format="rgb24")
if len(encoded) != expected_sizes[index] or decoded.shape != reference.shape:
raise RuntimeError("Файловый проход не совпал с основным кодированием")
latencies.append(time.perf_counter() - started)
peak_rss = max(peak_rss, PROCESS.memory_info().rss)
if encoder is not None and encoder.encode(None):
raise RuntimeError("H.264 сохранил отложенные пакеты после файлового прохода")
finally:
capture.release()
return {"end_to_end_file_pipeline_latency_s": summary(latencies),
"source_read_resize_time_per_frame_s": summary(source_costs),
"peak_process_rss_bytes": peak_rss}
def run(source: Path, output: Path, maxrate_bps: int, matrix=MATRIX,
windows=DEFAULT_WINDOWS) -> dict:
source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest()
results = []
for width, height, rates in matrix:
for fps in rates:
for window_name, start_seconds, duration_seconds in windows:
frames = source_frames(source, width, height, fps, start_seconds, duration_seconds)
source_hashes = {hashlib.sha256(frame.tobytes()).hexdigest() for frame in frames}
if len(source_hashes) < len(frames) // 2:
raise RuntimeError("В исходном окне слишком много повторных кадров")
temporal_change = statistics.mean(float(np.mean(np.abs(
later.astype(np.int16) - earlier.astype(np.int16))))
for earlier, later in zip(frames, frames[1:]))
for name, operation in (("jpeg", jpeg_case), ("h264", h264_case)):
suffix = f"_{maxrate_bps}" if name == "h264" else ""
example = (output.parent / "examples" /
f"{window_name}_{width}x{height}_{fps}_{name}{suffix}")
case = (operation(frames, fps, example) if name == "jpeg"
else operation(frames, fps, maxrate_bps, example))
sizes = case["frame_sizes_bytes"]
direct = end_to_end_file_case(
source, frames, fps, start_seconds, width, height,
case["codec"], maxrate_bps, sizes)
case["end_to_end_file_pipeline_latency_s"] = direct[
"end_to_end_file_pipeline_latency_s"]
case["source_read_resize_time_per_frame_s"] = direct[
"source_read_resize_time_per_frame_s"]
case["peak_process_rss_bytes"] = max(
case["peak_process_rss_bytes"], direct["peak_process_rss_bytes"])
case.update({"resolution": [width, height], "fps": fps,
"window": window_name,
"source_unique_frames": len(source_hashes),
"source_temporal_change_mean_absolute": temporal_change,
"unique_decoded_fps": case["unique_decoded_frames"] / duration_seconds,
"identical_decoded_frame_count": (
case["decoded_frame_count"] - case["unique_decoded_frames"]),
"average_bitrate_bps": 8 * sum(sizes) / duration_seconds,
"peak_one_second_bitrate_bps": peak_second(sizes, fps),
"average_frame_size_bytes": statistics.mean(sizes),
"max_frame_size_bytes": max(sizes),
"radio_screen": radio_screen(sizes, fps)})
results.append(case)
print(f"{width}x{height} {fps} {window_name} {case['codec']}: "
f"{case['average_bitrate_bps']:.0f} бит/с", flush=True)
document = {
"status": "MEASURED_SOFTWARE_ONLY", "rf_tx": 0,
"source": str(source), "source_sha256": source_sha256,
"windows": [{"name": name, "start_seconds": start, "duration_seconds": duration}
for name, start, duration in windows],
"radio_budget": {
"status": "MEASURED_ONE_BURST_VIDEO_EQUIVALENT",
"lab066_accepted_packets": 10, "lab066_transmitted_packets": 16,
"burst_seconds_including_guards": BURST_SECONDS,
"video_equivalent_goodput_bps": OBSERVED_VIDEO_EQUIVALENT_BPS,
"zero_loss_goodput_bps_with_5ms_gaps":
FRAGMENT_BYTES * 8 * SAMPLE_RATE / (PACKET_SAMPLES + GAP_SAMPLES),
"sustained_video_goodput": "NOT_ESTABLISHED",
"h264_over_current_fragment_transport": "NOT_IMPLEMENTED"},
"memory_measurement": "RSS всего процесса, пик при обработке случая; случаи в одном процессе",
"resize_method": "Пропорциональное уменьшение с чёрными полями, без растяжения и обрезки",
"latency_measurement": "file_pipeline_latency_s: от подачи готового кадра кодировщику до RGB; end_to_end_file_pipeline_latency_s: от чтения очередного кадра MP4 с пропуском промежуточных до RGB; оба без эфира и экрана",
"operator_quality_scope": "PSNR не заменяет проверку препятствий и читаемости оператором",
"cases": results,
}
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(document, ensure_ascii=False, indent=2, allow_nan=False) + "\n",
encoding="utf-8")
return document
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--h264-maxrate-bps", type=int, default=160_000)
parser.add_argument("--window", nargs=3, action="append", metavar=("NAME", "START_S", "DURATION_S"),
help="Окно ролика; параметр можно повторить")
arguments = parser.parse_args()
if arguments.h264_maxrate_bps < 10_000 or arguments.h264_maxrate_bps % 1000:
parser.error("maxrate должен быть не меньше 10 кбит/с и кратным 1000")
windows = DEFAULT_WINDOWS
if arguments.window:
try:
windows = tuple((name, float(start), float(duration))
for name, start, duration in arguments.window)
except ValueError:
parser.error("START_S и DURATION_S должны быть числами")
if any(start < 0 or duration <= 0 for _, start, duration in windows):
parser.error("окна должны иметь неотрицательное начало и положительную длительность")
run(arguments.source, arguments.output, arguments.h264_maxrate_bps, windows=windows)
if __name__ == "__main__":
main()

274
tools/video_gate.py Normal file
View File

@@ -0,0 +1,274 @@
"""VIDEO-001/002/003: проверки без данных аппаратных лабораторий и без RF."""
from __future__ import annotations
import ast
import argparse
import hashlib
import importlib
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import time
import av
import numpy as np
from tools.video003_design import ROOT, run as calculate_design
MODULES = (
"protocol.packet", "protocol.bpsk_radio", "protocol.convolutional_fec",
"protocol.video_stream_transport", "protocol.punctured_fec",
"protocol.video003_modem", "protocol.video003_compact_header",
"protocol.video004_viterbi", "protocol.video004_erasure",
"tools.video_compare", "tools.video002_radio_budget",
"tools.video002_simulation", "tools.video003_design",
"tools.video003_synthetic", "tools.video003_stream",
"tools.video003_codec", "tools.video003_transport",
"tools.video004_benchmark", "tools.video004_transport",
"tools.video004_interleaving",
"tools.video004_viterbi_benchmark", "tools.video004_longrun",
"tools.video004_pipeline_benchmark",
"tools.video004_keyframe", "tools.video004_seed_sweep",
)
TESTS = (
"tests/test_packet.py", "tests/test_bpsk_radio.py",
"tests/test_video_001_compare.py", "tests/test_video002_codec.py",
"tests/test_video002_transport.py", "tests/test_video003_modem.py",
"tests/test_video003_design.py", "tests/test_video003_transport.py",
"tests/test_video003_stream.py",
"tests/test_video004_viterbi.py", "tests/test_video004_erasure.py",
"tests/test_video004_transport.py",
"tests/test_video004_pipeline.py",
"tests/test_video004_keyframe.py",
)
ARTIFACTS = {
"video001_160": "data/processed/video001/comparison_traffic_160k.json",
"video001_320": "data/processed/video001/comparison_traffic_320k.json",
"video002": "data/processed/video002/results.json",
"design": "data/processed/video003/design_space.json",
"synthetic": "data/processed/video003/synthetic_phy.json",
"stream": "data/processed/video003/continuous_stream.json",
"codec": "data/processed/video003/codec_results.json",
"transport": "data/processed/video003/transport_results.json",
"benchmark_manifest": "data/processed/video004/canonical_manifest.json",
"benchmark_before": "data/processed/video004/benchmark_before.json",
"benchmark_after": "data/processed/video004/benchmark_after.json",
"transport004": "data/processed/video004/transport_results.json",
"interleaving004": "data/processed/video004/interleaving_results.json",
"viterbi004": "data/processed/video004/viterbi_benchmark.json",
"long004": "data/processed/video004/benchmark_256.json",
"pipeline_manifest004": "data/processed/video004/pipeline_manifest.json",
"pipeline004": "data/processed/video004/pipeline_benchmark.json",
"keyframe004": "data/processed/video004/keyframe_recovery.json",
"seed_sweep004": "data/processed/video004/normal_seed_sweep.json",
}
FORBIDDEN_IMPORT_ROOTS = {"iio", "adi", "SoapySDR", "hackrf", "pylibiio", "pyhackrf"}
def _check_imports_and_hardware_boundary() -> None:
for name in MODULES:
module = importlib.import_module(name)
source_path = Path(module.__file__).resolve()
if not source_path.is_relative_to(ROOT.resolve()):
raise AssertionError(f"локальный модуль вне worktree: {name}")
tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
for node in ast.walk(tree):
imported = []
if isinstance(node, ast.Import):
imported = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module:
imported = [node.module]
for imported_name in imported:
if imported_name.split(".")[0] in FORBIDDEN_IMPORT_ROOTS:
raise AssertionError(f"аппаратный импорт {imported_name} в {name}")
def _load_artifacts() -> dict[str, dict]:
documents = {}
for label, relative in ARTIFACTS.items():
path = ROOT / relative
if not path.is_file():
raise AssertionError(f"нет результата: {relative}")
documents[label] = json.loads(path.read_text(encoding="utf-8"))
source = ROOT / "data/raw/video001/udacity_project_video.mp4"
digest = hashlib.sha256(source.read_bytes()).hexdigest()
for label in ("video001_160", "video001_320", "video002", "codec", "transport"):
if documents[label]["source_sha256"] != digest:
raise AssertionError(f"SHA исходного ролика не совпал: {label}")
return documents
def _check_results(documents: dict[str, dict]) -> None:
design = documents["design"]
if design != calculate_design():
raise AssertionError("расчётная таблица отличается от сохранённой")
if design["best_calculated"]["useful_video_rate_bps"] >= 600_000:
raise AssertionError("таблица неверно объявила достижение 600 кбит/с")
synthetic = documents["synthetic"]
if len(synthetic["records"]) != 7 * 4 * synthetic["trials_per_point"]:
raise AssertionError("число реализаций AWGN не совпадает")
if len(synthetic["summary"]) != 28:
raise AssertionError("неполная сводка AWGN")
if not all(item["packet_crc_pass"] and item["packet_exact"]
for item in synthetic["rrc_controls"]):
raise AssertionError("RRC-контроль не прошёл")
if not all(item["crc_pass_count"] == item["trials"]
for item in synthetic["summary"] if item["ebn0_db"] == 9.0):
raise AssertionError("эталонный AWGN-контроль 9 dB изменился")
stream = documents["stream"]
if len(stream["results"]) != 2 or stream["packet_count"] != 32:
raise AssertionError("неполный непрерывный поток")
for item in stream["results"]:
if (item["crc_pass_count"] != 32 or item["exact_packet_count"] != 32
or item["verified_video_fragment_count"] != 32
or abs(item["offline_verified_video_bps"]
- item["calculated_zero_loss_video_bps"]) > 1e-6):
raise AssertionError("непрерывный поток не совпал с расчётом")
codec = documents["codec"]
if len(codec["cases"]) != 60 or av.__version__ != "16.0.1":
raise AssertionError("неполный кодековый опыт или иная версия PyAV")
for case in codec["cases"]:
settings = case["settings"]
if (settings["ffmpeg_cli_used"] or settings["profile_actual"] is None
or settings["codec_name"] != "libx264"
or case["keyframe_positions"][0] != 0
or case["frame_count"] != len(case["frame_sizes_bytes"])):
raise AssertionError("неполная конфигурация или размеры H.264")
calculated_average = sum(case["frame_sizes_bytes"]) * 8 * case["settings"]["frame_rate"] / case["frame_count"]
if abs(calculated_average - case["actual_average_bitrate_bps"]) > 1e-6:
raise AssertionError("средний поток H.264 не совпал с размерами кадров")
for case in (item for item in codec["cases"] if item["profile"] == "CURRENT"):
originals = [item for item in documents["video001_320"]["cases"]
if item["window"] == case["window"]
and item["resolution"] == case["settings"]["resolution"]
and item["fps"] == case["settings"]["frame_rate"]
and item["codec"] == "H264_LIBX264"]
if len(originals) != 1 or case["frame_sizes_bytes"] != originals[0]["frame_sizes_bytes"]:
raise AssertionError("CURRENT не повторил покадровые размеры VIDEO-001")
transport = documents["transport"]
if len(transport["cases"]) != 784:
raise AssertionError("неполная матрица доставки")
for case in transport["cases"]:
if not case["no_old_frame_replay"]:
raise AssertionError("повторно показан старый кадр")
if case["p95_end_to_end_age_ms"] is not None:
if case["p95_end_to_end_age_ms"] > case["max_end_to_end_age_ms"]:
raise AssertionError("P95 возраста больше максимума")
selected_clean = [case for case in transport["cases"]
if case["codec_profile"] == "SHORT_GOP_400K"
and case["link"] == "QPSK_500_R23_1012_0MS"
and case["scenario"] == "random" and case["level"] == 0]
if len(selected_clean) != 6 or any(case["displayed_frames"] != case["input_frames"]
for case in selected_clean):
raise AssertionError("контрольный видеотракт не показал все кадры без потерь")
if any(documents[label]["rf_tx"] != 0
for label in ("video002", "design", "synthetic", "stream", "codec", "transport",
"benchmark_manifest", "benchmark_before", "benchmark_after",
"transport004", "interleaving004", "viterbi004", "long004",
"pipeline_manifest004", "pipeline004", "keyframe004",
"seed_sweep004")):
raise AssertionError("в результатах найден ненулевой RF TX")
from tools.video004_benchmark import load_fixture, run as benchmark_run
_, manifest = load_fixture()
before, after = documents["benchmark_before"], documents["benchmark_after"]
if (before["signal_sha256"] != after["signal_sha256"]
or before["signal_sha256"] != manifest["signal_sha256"]
or before["outcomes"] != after["outcomes"]):
raise AssertionError("эталонные выходы приёмника до/после изменились")
measured = benchmark_run("after", repetitions=3)
if (measured["outcomes"] != before["outcomes"]
or measured["available_path_realtime_factor"] > 0.67):
raise AssertionError("производительность или строгий приём VIDEO-004 изменились")
protected = documents["transport004"]
interleaving = documents["interleaving004"]
if (len(protected["cases"]) != 1440 or len(interleaving["cases"]) != 48
or protected["source_sha256"] != documents["codec"]["source_sha256"]
or interleaving["source_sha256"] != documents["codec"]["source_sha256"]):
raise AssertionError("неполная матрица внешнего FEC")
if any(not case["no_old_frame_replay"] for case in
protected["cases"] + interleaving["cases"]):
raise AssertionError("повторно показан старый кадр VIDEO-004")
if any(case["max_frame_age_ms"] is not None and case["max_frame_age_ms"] >= 300
for case in protected["cases"] + interleaving["cases"]):
raise AssertionError("показан просроченный кадр VIDEO-004")
selected = [case for case in protected["cases"]
if case["mode"] == "NORMAL" and case["fec_rate"] == "2/3"
and case["data_count"] == 8 and case["parity_count"] == 2
and not case["keyframe_boost"] and case["scenario"] == "random"]
if len(selected) != 12 or sum(case["displayed_frames"] for case in selected
if case["level"] == 0) != 120:
raise AssertionError("выбранный внешний FEC теряет кадры без потерь канала")
viterbi = documents["viterbi004"]
long_run = documents["long004"]
pipeline = documents["pipeline004"]
if (not viterbi["bits_equal"] or viterbi["signal_sha256"] != manifest["signal_sha256"]
or not long_run["strict_outputs_equal"] or long_run["packet_count"] != 256
or not pipeline["exact_packets_and_frames"] or pipeline["parity_recoveries"] != 4
or pipeline["numba"]["realtime_factor"] > 0.67):
raise AssertionError("длинный или полный программный тракт VIDEO-004 не прошёл")
from tools.video004_pipeline_benchmark import SIGNAL, decode
samples = np.load(SIGNAL, allow_pickle=False)
if hashlib.sha256(samples.tobytes()).hexdigest() != documents["pipeline_manifest004"]["signal_sha256"]:
raise AssertionError("контрольный сигнал внешнего FEC изменился")
started = time.perf_counter()
current = decode(samples, "numba")
elapsed = time.perf_counter() - started
if (current["packet_sha256"] != documents["pipeline_manifest004"]["expected_packet_sha256"]
or current["frame_sha256"] != documents["pipeline_manifest004"]["expected_frame_sha256"]
or elapsed / pipeline["signal_duration_s"] > 0.67):
raise AssertionError("полный доступный программный тракт изменился")
keyframe = documents["keyframe004"]
sweep = documents["seed_sweep004"]
if (len(keyframe["cases"]) != 24 or len(sweep["cases"]) != 180
or sweep["seeds"] != list(range(20260916, 20260921))):
raise AssertionError("неполная проверка опорного кадра или затравок")
base_double = [case for case in keyframe["cases"] if case["fec_rate"] == "2/3"
and case["data_count"] == 8 and case["parity_count"] == 1
and not case["keyframe_boost"] and case["scenario"] == "keyframe_double"]
boosted_double = [case for case in keyframe["cases"] if case["fec_rate"] == "2/3"
and case["data_count"] == 8 and case["parity_count"] == 1
and case["keyframe_boost"] and case["scenario"] == "keyframe_double"]
if (len(base_double) != 2 or len(boosted_double) != 2
or sum(x["displayed_frames"] for x in base_double) != 110
or sum(x["displayed_frames"] for x in boosted_double) != 120):
raise AssertionError("защита опорного кадра изменила результат")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--numba-packages", type=Path)
args = parser.parse_args()
if args.numba_packages is not None:
if not args.numba_packages.is_dir():
parser.error("каталог локальных пакетов Numba отсутствует")
sys.path.insert(0, str(args.numba_packages.resolve()))
_check_imports_and_hardware_boundary()
documents = _load_artifacts()
_check_results(documents)
output_root = (ROOT / "data/processed/video003").resolve()
if not output_root.is_relative_to(ROOT.resolve()):
raise AssertionError("временный каталог вне worktree")
environment = os.environ.copy()
environment["PYTHONDONTWRITEBYTECODE"] = "1"
if args.numba_packages is not None:
environment["PYTHONPATH"] = str(args.numba_packages.resolve()) + os.pathsep + environment.get("PYTHONPATH", "")
with tempfile.TemporaryDirectory(prefix="video_gate_pytest_", dir=output_root) as name:
temporary = Path(name).resolve()
if not temporary.is_relative_to(output_root):
raise AssertionError("временный каталог вне результата VIDEO-003")
command = [sys.executable, "-B", "-m", "pytest", *TESTS,
"-q", "-p", "no:cacheprovider", "--basetemp", str(temporary)]
completed = subprocess.run(command, cwd=ROOT, env=environment, check=False)
if completed.returncode != 0:
raise SystemExit(completed.returncode)
print("VIDEO-SPECIFIC GATE: PASS; результаты, импорты, аппаратная граница и тесты")
if __name__ == "__main__":
main()

113
tools/video_iq_replay.py Normal file
View File

@@ -0,0 +1,113 @@
"""VIDEO-001: лёгкий повтор сохранённого IQ через штатный приёмник Lab053.
Не обращается к SDR. Внутрь RX передаются только последовательные блоки IQ.
Ожидаемые SHA читаются лишь после строгого PHY/FEC/CRC и сборки JPEG.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import sys
import tempfile
import time
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
SAMPLE_RATE = 8_000_000
BLOCK_SAMPLES = 65_536
def replay(source: Path) -> dict:
from experiments.lab051_receiver_performance import ImageSink
from experiments.lab053_streaming_receiver import StreamingReceiver
receiver = StreamingReceiver(workers=1, candidate_capacity=4)
startup = dict(receiver.processor.startup)
sink = ImageSink()
images: list[dict] = []
bytes_read = 0
start = time.perf_counter()
def consume(packets) -> None:
for packet in packets:
item = sink.offer(packet, time.perf_counter() - start)
if item is not None:
images.append({"image_id": item["image_id"],
"sha256": hashlib.sha256(item["jpeg"]).hexdigest(),
"dimensions": item["dimensions"]})
try:
with source.open("rb") as stream:
while raw := stream.read(BLOCK_SAMPLES * 8):
if len(raw) % 8:
raise ValueError("IQ содержит неполный complex64 отсчёт")
bytes_read += len(raw)
consume(receiver.push_iq_block(np.frombuffer(raw, dtype=np.complex64)))
consume(receiver.finish())
elapsed = time.perf_counter() - start
packet_count = receiver.packet_count
candidate_count = receiver.candidate_count
finally:
receiver.close()
iq_seconds = bytes_read / 8 / SAMPLE_RATE
return {"startup": startup, "iq_bytes": bytes_read, "iq_seconds": iq_seconds,
"replay_wall_seconds": elapsed,
"replay_wall_over_iq_seconds": elapsed / iq_seconds,
"iq_seconds_over_replay_wall": iq_seconds / elapsed,
"total_with_jit_warmup_seconds": elapsed + startup["warmup_seconds"],
"accepted_packets": packet_count, "strict_candidates": candidate_count,
"completed_images": len(images), "images": images}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
parser.add_argument("--numba-packages", required=True, type=Path)
parser.add_argument("--fixture-json", required=True, type=Path)
arguments = parser.parse_args()
if not arguments.source.is_file() or not arguments.fixture_json.is_file():
parser.error("IQ или зафиксированная ведомость JPEG отсутствует")
if not arguments.numba_packages.is_dir():
parser.error("Локальные пакеты Numba отсутствуют")
if arguments.source.stat().st_size % 8:
parser.error("IQ содержит неполный complex64 отсчёт")
sys.path.insert(0, str(arguments.numba_packages))
fixture = json.loads(arguments.fixture_json.read_text(encoding="utf-8"))
expected = {item["image_id"]: item["sha256"] for item in fixture["images"]}
with tempfile.TemporaryDirectory(prefix="video001_numba_") as cache:
os.environ["NUMBA_CACHE_DIR"] = cache
first = replay(arguments.source)
repeat = replay(arguments.source)
for result in (first, repeat):
result["exact_images_after_verdict"] = sum(
expected.get(item["image_id"]) == item["sha256"] for item in result["images"]
)
result["all_expected_images_exact"] = (
result["completed_images"] == len(expected)
and result["exact_images_after_verdict"] == len(expected)
)
result.pop("images")
document = {"status": "MEASURED_SAVED_IQ_NO_SDR", "source": str(arguments.source),
"source_bytes": arguments.source.stat().st_size,
"comparison": "Первый и повторный проходы в одном процессе; пустой JIT-кеш в начале",
"path_scope": "Время включает чтение файла, RX, сборку и JPEG decode; без графиков, послеблочной истории и сравнения SHA внутри измерения",
"first": first, "repeat": repeat, "rf_tx": 0}
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(json.dumps(document, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8")
print(json.dumps({label: {name: value[name] for name in ("startup", "replay_wall_seconds",
"iq_seconds_over_replay_wall", "accepted_packets", "completed_images",
"all_expected_images_exact")} for label, value in (("first", first), ("repeat", repeat))},
ensure_ascii=False), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,91 @@
"""Обновить числовую таблицу VIDEO-001 по сохранённым результатам."""
from __future__ import annotations
import argparse
from collections import defaultdict
import json
from pathlib import Path
MATRIX = (((320, 240), (5, 10, 15)), ((640, 360), (5, 10, 15)),
((640, 480), (5, 10)), ((854, 480), (5, 10)), ((1280, 720), (5,)))
# Это качественные оценки контрольных кадров, а не результат автоматической метрики.
QUALITY = {
(320, 240, "jpeg"): "Ближние машины и полоса видны; дальние предметы мелки",
(320, 240, "160k"): "Ближние машины и направление видны; мелкие детали ограничены",
(320, 240, "320k"): "Почти тот же вид, что при 160k",
(640, 360, "jpeg"): "Машины и разметка видны; крупный поток",
(640, 360, "160k"): "Машины видны; текстура дороги размыта при движении",
(640, 360, "320k"): "Машины, полоса и направление различимы",
(640, 480, "jpeg"): "Машины видны; чёрные поля 60 пикселей",
(640, 480, "160k"): "Машины видны; поля и размытие деталей",
(640, 480, "320k"): "Машины видны; полезная область как 640×360",
(854, 480, "jpeg"): "Машины и разметка чётче; крупный поток",
(854, 480, "160k"): "Ближние машины видны; дальние детали размыты",
(854, 480, "320k"): "Машины, полосы и направление хорошо различимы",
(1280, 720, "jpeg"): "Машины и разметка подробны; поток велик",
(1280, 720, "160k"): "Машины видны; разрешение не даёт ожидаемой детализации",
(1280, 720, "320k"): "Машины и разметка видны; дальние надписи не проверены",
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--comparison-160k", type=Path, required=True)
parser.add_argument("--comparison-320k", type=Path, required=True)
parser.add_argument("--report", type=Path, required=True)
arguments = parser.parse_args()
datasets = {
code: json.loads(path.read_text(encoding="utf-8"))
for code, path in (("160k", arguments.comparison_160k),
("320k", arguments.comparison_320k))
}
if datasets["160k"]["source_sha256"] != datasets["320k"]["source_sha256"]:
raise ValueError("Наборы сравнения используют разные исходные ролики")
if datasets["160k"]["windows"] != datasets["320k"]["windows"]:
raise ValueError("Наборы сравнения используют разные участки ролика")
cases = defaultdict(list)
for code, dataset in datasets.items():
for case in dataset["cases"]:
key = (tuple(case["resolution"]), case["fps"], case["codec"], code)
cases[key].append(case)
rows = [
"| Разрешение | FPS | Кодек | Средний поток | Пик 1 с | Кодирование | "
"Декодирование | Качество для оператора | FITS CURRENT RADIO BUDGET |",
"|---|---:|---|---:|---:|---:|---:|---|---|",
]
for resolution, rates in MATRIX:
for fps in rates:
for codec, code, label, quality_code in (
("JPEG_INDEPENDENT", "160k", "JPEG q35", "jpeg"),
("H264_LIBX264", "160k", "H.264 160k", "160k"),
("H264_LIBX264", "320k", "H.264 320k", "320k"),
):
pair = cases[(resolution, fps, codec, code)]
if len(pair) != 2:
raise ValueError(f"Ожидались два участка: {resolution}, {fps}, {label}")
average = max(case["average_bitrate_bps"] for case in pair) / 1000
peak = max(case["peak_one_second_bitrate_bps"] for case in pair) / 1000
encode = max(case["encode_time_per_frame_s"]["mean"] for case in pair) * 1000
decode = max(case["decode_time_per_frame_s"]["mean"] for case in pair) * 1000
if any(case["radio_screen"]["fits_current_radio_budget"] !=
"NO_ON_OBSERVED_BURST_SCREEN" for case in pair):
raise ValueError(f"Недостаточно оснований для НЕТ: {resolution}, {fps}, {label}")
quality = QUALITY[(*resolution, quality_code)]
encode_text = f"{encode:.1f}".replace(".", ",")
decode_text = f"{decode:.1f}".replace(".", ",")
rows.append(f"| {resolution[0]}×{resolution[1]} | {fps} | {label} | "
f"{average:.0f} | {peak:.0f} | "
f"{encode_text} | {decode_text} | {quality} | НЕТ |")
report = arguments.report.read_text(encoding="utf-8")
start = report.index("| Разрешение | FPS | Кодек |")
end = report.index("\n\n", start)
arguments.report.write_text(report[:start] + "\n".join(rows) + report[end:],
encoding="utf-8")
print(f"Обновлено строк таблицы: {len(rows) - 2}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,80 @@
"""Проверить сохранённые измерения VIDEO-001 без повторного кодирования."""
from __future__ import annotations
import argparse
import hashlib
import json
import math
from pathlib import Path
from video_compare import FRAGMENT_BYTES, MATRIX, peak_second, radio_screen
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("results", nargs=3, type=Path)
arguments = parser.parse_args()
source_sha = hashlib.sha256(arguments.source.read_bytes()).hexdigest()
expected_modes = {(width, height, fps)
for width, height, rates in MATRIX for fps in rates}
all_cases = 0
for path in arguments.results:
document = json.loads(path.read_text(encoding="utf-8"))
assert document["status"] == "MEASURED_SOFTWARE_ONLY"
assert document["source_sha256"] == source_sha
assert document["rf_tx"] == 0
cases = document["cases"]
assert len(cases) == 2 * len(expected_modes) * 2
modes = {(case["resolution"][0], case["resolution"][1], case["fps"])
for case in cases}
assert modes == expected_modes
assert len({(tuple(case["resolution"]), case["fps"], case["window"],
case["codec"]) for case in cases}) == len(cases)
durations = {window["name"]: window["duration_seconds"]
for window in document["windows"]}
assert len(durations) == 2
for case in cases:
frames = case["frame_sizes_bytes"]
fps = case["fps"]
assert len(frames) == round(durations[case["window"]] * fps)
assert all(size > 0 for size in frames)
assert case["decoded_frame_count"] == len(frames)
assert case["source_unique_frames"] == len(frames)
assert case["unique_decoded_frames"] == len(frames)
assert case["identical_decoded_frame_count"] == 0
assert case["actual_encoded_fps"] > fps
file_latency = case["end_to_end_file_pipeline_latency_s"]
source_time = case["source_read_resize_time_per_frame_s"]
assert 0 < source_time["mean"] <= file_latency["mean"]
assert source_time["max"] <= file_latency["max"]
assert file_latency["mean"] <= file_latency["max"]
assert case["peak_process_rss_bytes"] > 0
assert math.isclose(case["average_bitrate_bps"],
8 * sum(frames) / durations[case["window"]])
assert case["peak_one_second_bitrate_bps"] == peak_second(frames, fps)
assert case["average_frame_size_bytes"] == sum(frames) / len(frames)
assert case["max_frame_size_bytes"] == max(frames)
expected_radio = radio_screen(frames, fps)
assert case["radio_screen"] == expected_radio
assert expected_radio["fits_current_radio_budget"] == \
"NO_ON_OBSERVED_BURST_SCREEN"
if case["codec"] == "JPEG_INDEPENDENT":
assert case["loss_recovery"]["first_exact_frame"] == 1
assert case["reference_frame_size_bytes"] == max(frames)
else:
assert case["codec"] == "H264_LIBX264"
assert case["loss_recovery"]["lost_reference"]["first_exact_frame"] == fps
assert case["loss_recovery"]["lost_delta"]["first_exact_frame"] == fps
assert case["reference_frame_size_bytes"] in case["keyframe_sizes_bytes"]
assert case["reference_frame_size_bytes"] == max(case["keyframe_sizes_bytes"])
assert expected_radio["max_packets_per_frame"] == max(
math.ceil(size / FRAGMENT_BYTES) for size in frames)
all_cases += len(cases)
print(f"{path}: {len(cases)} режима/участка, согласовано")
print(f"Всего проверено: {all_cases} прохода; RF TX = 0")
if __name__ == "__main__":
main()