Complete VIDEO-001 through VIDEO-004 software video studies
This commit is contained in:
188
tools/video003_codec.py
Normal file
188
tools/video003_codec.py
Normal 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()
|
||||
Reference in New Issue
Block a user