279 lines
14 KiB
Python
279 lines
14 KiB
Python
"""Доставка настоящего 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()
|