Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
84
tools/video002_radio_budget.py
Normal file
84
tools/video002_radio_budget.py
Normal 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)
|
||||
289
tools/video002_simulation.py
Normal file
289
tools/video002_simulation.py
Normal 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
188
tools/video003_codec.py
Normal 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
170
tools/video003_design.py
Normal 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
134
tools/video003_stream.py
Normal 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
147
tools/video003_synthetic.py
Normal 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
117
tools/video003_transport.py
Normal 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
200
tools/video004_benchmark.py
Normal 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()
|
||||
47
tools/video004_interleaving.py
Normal file
47
tools/video004_interleaving.py
Normal 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()
|
||||
45
tools/video004_keyframe.py
Normal file
45
tools/video004_keyframe.py
Normal 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
72
tools/video004_longrun.py
Normal 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()
|
||||
178
tools/video004_pipeline_benchmark.py
Normal file
178
tools/video004_pipeline_benchmark.py
Normal 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()
|
||||
51
tools/video004_seed_sweep.py
Normal file
51
tools/video004_seed_sweep.py
Normal 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
278
tools/video004_transport.py
Normal 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()
|
||||
81
tools/video004_viterbi_benchmark.py
Normal file
81
tools/video004_viterbi_benchmark.py
Normal 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
448
tools/video_compare.py
Normal 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
274
tools/video_gate.py
Normal 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
113
tools/video_iq_replay.py
Normal 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()
|
||||
91
tools/video_report_table.py
Normal file
91
tools/video_report_table.py
Normal 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()
|
||||
80
tools/video_validate_results.py
Normal file
80
tools/video_validate_results.py
Normal 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()
|
||||
Reference in New Issue
Block a user