Complete VIDEO-001 through VIDEO-004 software video studies

This commit is contained in:
LittleSam129
2026-09-16 18:27:35 +03:00
parent af7c05f2da
commit 95f664076a
403 changed files with 392009 additions and 0 deletions

278
tools/video004_transport.py Normal file
View 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()