Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
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()
|
||||
Reference in New Issue
Block a user