Files
SDR-Rover/tools/video002_simulation.py

290 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()