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