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

188
tools/video003_codec.py Normal file
View File

@@ -0,0 +1,188 @@
"""VIDEO-003: сравнение H.264 через PyAV/libx264 на исходнике VIDEO-001."""
from __future__ import annotations
import argparse
from fractions import Fraction
import hashlib
import json
import math
from pathlib import Path
import statistics
import av
import numpy as np
from tools.video_compare import recovery_after_drop, source_frames
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / "data/raw/video001/udacity_project_video.mp4"
BASELINE = ROOT / "data/processed/video001/comparison_traffic_320k.json"
DEFAULT_OUTPUT = ROOT / "data/processed/video003/codec_results.json"
VIDEO_MODES = (("NORMAL", 640, 360, 10), ("HIGH_QUALITY", 854, 480, 10),
("720P5", 1280, 720, 5))
PROFILES = ("CURRENT", "CURRENT_VBV", "SHORT_GOP", "INTRA_REFRESH")
BITRATES = (300_000, 400_000, 500_000)
def _encoder(width: int, height: int, fps: int, profile: str,
target_bps: int | None) -> tuple[av.CodecContext, dict]:
if profile not in PROFILES:
raise ValueError("неизвестный профиль H.264")
if profile == "CURRENT" and target_bps is not None:
raise ValueError("текущий профиль использует только исходный VBV 320k")
if profile != "CURRENT" and target_bps not in BITRATES:
raise ValueError("для профиля VBV нужен target 300/400/500 кбит/с")
encoder = av.CodecContext.create("libx264", "w")
encoder.width, encoder.height = width, height
encoder.pix_fmt = "yuv420p"
encoder.time_base = Fraction(1, fps)
encoder.framerate = Fraction(fps, 1)
encoder.thread_count = 1
encoder.max_b_frames = 0
gop_size = math.ceil(fps / 2) if profile == "SHORT_GOP" else fps
encoder.gop_size = gop_size
if profile != "CURRENT":
encoder.bit_rate = target_bps
maxrate_bps = 320_000 if profile == "CURRENT" else target_bps
buffer_bps = maxrate_bps if profile == "CURRENT" else round(target_bps * 0.2)
intra_refresh = profile == "INTRA_REFRESH"
parameters = (
f"keyint={gop_size}:min-keyint={gop_size}:scenecut=0:open-gop=0:"
f"bframes=0:rc-lookahead=0:sync-lookahead=0:repeat-headers=1:"
f"vbv-maxrate={maxrate_bps // 1000}:vbv-bufsize={buffer_bps // 1000}:"
f"intra-refresh={int(intra_refresh)}"
)
options = {"preset": "veryfast", "tune": "zerolatency", "crf": "28",
"x264-params": parameters}
encoder.options = options.copy()
encoder.open()
settings = {
"encoder_api": f"PyAV {av.__version__}",
"ffmpeg_libraries": {name: ".".join(map(str, version))
for name, version in av.library_versions.items()},
"ffmpeg_cli_used": False,
"codec_name": "libx264", "resolution": [width, height], "frame_rate": fps,
"pixel_format": "yuv420p", "bitrate_target_bps": target_bps,
"max_bitrate_bps": maxrate_bps, "buffer_size_bits": buffer_bps,
"buffer_duration_s": buffer_bps / maxrate_bps,
"gop_size_frames": gop_size, "b_frame_count": 0,
"preset": options["preset"], "tune": options["tune"],
"profile_requested": None, "profile_actual": encoder.profile,
"intra_refresh_option": intra_refresh,
"time_base": f"1/{fps}", "codec_options": options,
"bitrate_property_after_open_bps": encoder.bit_rate,
}
return encoder, settings
def _peak_rate(frame_sizes: list[int], fps: int, window_ms: int) -> float:
"""Максимум битов по полуоткрытому временному окну длиной window_ms."""
frame_times = [index * 1000 / fps for index in range(len(frame_sizes))]
best_bits = 0
for start in frame_times:
bits = sum(size * 8 for time_ms, size in zip(frame_times, frame_sizes)
if start <= time_ms < start + window_ms - 1e-9)
best_bits = max(best_bits, bits)
return best_bits * 1000 / window_ms
def encode_case(frames: list[np.ndarray], fps: int, profile: str,
target_bps: int | None) -> tuple[dict, list[bytes]]:
height, width = frames[0].shape[:2]
encoder, settings = _encoder(width, height, fps, profile, target_bps)
packets: list[bytes] = []
keyframes: list[int] = []
for index, pixels in enumerate(frames):
frame = av.VideoFrame.from_ndarray(pixels, format="rgb24").reformat(format="yuv420p")
frame.pts = index
encoded = encoder.encode(frame)
if len(encoded) != 1:
raise ValueError("ожидался один немедленный H.264 packet на кадр")
if encoded[0].is_keyframe:
keyframes.append(index)
packets.append(bytes(encoded[0]))
if encoder.encode(None):
raise ValueError("кодировщик задержал пакеты вопреки low-latency режиму")
decoder = av.CodecContext.create("h264", "r")
decoder.open()
clean_hashes = {}
psnr_values = []
decoded_count = 0
for index, data in enumerate(packets):
packet = av.Packet(data)
packet.pts = packet.dts = index
packet.time_base = Fraction(1, fps)
decoded = decoder.decode(packet)
if len(decoded) != 1 or decoded[0].pts != index:
raise ValueError("чистое декодирование не дало кадр с ожидаемым номером")
pixels = decoded[0].to_ndarray(format="rgb24")
clean_hashes[index] = hashlib.sha256(pixels.tobytes()).hexdigest()
if index == 0:
settings["profile_actual"] = decoder.profile
error = float(np.mean((frames[index].astype(np.float32)
- pixels.astype(np.float32)) ** 2))
psnr_values.append(10 * math.log10(255 * 255 / max(error, 1e-12)))
decoded_count += 1
sizes = [len(packet) for packet in packets]
dropped_frame = min(fps + 1, len(packets) - 2)
recovery = recovery_after_drop(packets, dropped_frame, fps, clean_hashes)
recovered_at = recovery["first_exact_frame"]
result = {
"settings": settings,
"frame_count": len(packets), "decoded_frame_count": decoded_count,
"frame_sizes_bytes": sizes, "keyframe_positions": keyframes,
"actual_average_bitrate_bps": sum(sizes) * 8 * fps / len(sizes),
"peak_100ms_bitrate_bps": _peak_rate(sizes, fps, 100),
"peak_1s_bitrate_bps": _peak_rate(sizes, fps, 1000),
"max_frame_size_bytes": max(sizes),
"p95_frame_size_bytes": float(np.percentile(sizes, 95)),
"quality": {"mean_psnr_db": statistics.mean(psnr_values),
"min_psnr_db": min(psnr_values),
"operator_visual_review": "NOT_RUN"},
"loss_recovery": {"dropped_frame": dropped_frame,
"first_exact_frame": recovered_at,
"recovery_time_ms": ((recovered_at - dropped_frame) * 1000 / fps
if recovered_at is not None else None),
"decoder_errors": recovery["decoder_errors"]},
}
return result, packets
def run() -> dict:
baseline = json.loads(BASELINE.read_text(encoding="utf-8"))
digest = hashlib.sha256(SOURCE.read_bytes()).hexdigest()
if digest != baseline["source_sha256"]:
raise ValueError("исходное видео не совпадает с VIDEO-001")
results = []
for mode, width, height, fps in VIDEO_MODES:
for window in baseline["windows"]:
frames = source_frames(SOURCE, width, height, fps,
window["start_seconds"], window["duration_seconds"])
for profile in PROFILES:
targets = (None,) if profile == "CURRENT" else BITRATES
for target in targets:
item, _ = encode_case(frames, fps, profile, target)
item.update({"mode": mode, "window": window["name"],
"profile": profile, "target_bps": target})
results.append(item)
print(f"{mode}/{window['name']}/{profile}/{target}: "
f"{item['actual_average_bitrate_bps'] / 1000:.1f} кбит/с",
flush=True)
return {"source": str(SOURCE.relative_to(ROOT)), "source_sha256": digest,
"cases": results, "rf_tx": 0}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=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-003: {len(result['cases'])} H.264 прогонов; {args.output}")
if __name__ == "__main__":
main()