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