"""VIDEO-001: JPEG и libx264 на одном сохранённом видеоролике, без SDR.""" from __future__ import annotations import argparse from fractions import Fraction import hashlib from io import BytesIO import json import math import os from pathlib import Path import statistics import time import av import cv2 import numpy as np from PIL import Image import psutil MATRIX = ((320, 240, (5, 10, 15)), (640, 360, (5, 10, 15)), (640, 480, (5, 10)), (854, 480, (5, 10)), (1280, 720, (5,))) DEFAULT_WINDOWS = (("white_car", 7.0, 6.0), ("black_car", 33.0, 6.0)) PROCESS = psutil.Process(os.getpid()) FRAGMENT_BYTES = 244 SAMPLE_RATE = 8_000_000 PACKET_SAMPLES = 92_256 GAP_SAMPLES = 40_000 BURST_SECONDS = (16 * PACKET_SAMPLES + 15 * GAP_SAMPLES + 2 * 800_000) / SAMPLE_RATE OBSERVED_VIDEO_EQUIVALENT_BPS = 10 * FRAGMENT_BYTES * 8 / BURST_SECONDS def summary(values: list[float]) -> dict: return {"mean": statistics.mean(values), "median": statistics.median(values), "max": max(values)} def peak_second(frame_sizes: list[int], fps: int) -> int: return 8 * max(sum(frame_sizes[start:start + fps]) for start in range(len(frame_sizes) - fps + 1)) def radio_screen(frame_sizes: list[int], fps: int) -> dict: packets = [math.ceil(size / FRAGMENT_BYTES) for size in frame_sizes] peak_packets = max(sum(packets[start:start + fps]) for start in range(len(packets) - fps + 1)) observed_packets_per_second = 10 / BURST_SECONDS zero_loss_packets_per_second = SAMPLE_RATE / (PACKET_SAMPLES + GAP_SAMPLES) return { "peak_packet_slots_per_second": peak_packets, "mean_packets_per_frame": statistics.mean(packets), "max_packets_per_frame": max(packets), "airtime_for_peak_second_s": peak_packets / zero_loss_packets_per_second, "zero_loss_packet_slots_per_second_with_5ms_gaps": zero_loss_packets_per_second, "observed_one_burst_accepted_packets_per_second": observed_packets_per_second, "fits_current_radio_budget": ( "NO_ON_OBSERVED_BURST_SCREEN" if peak_packets > observed_packets_per_second else "NOT_ESTABLISHED_NO_SUSTAINED_GOODPUT_TEST"), } def fit_frame(frame: np.ndarray, width: int, height: int) -> np.ndarray: """Уместить исходный кадр целиком, сохранив соотношение сторон.""" source_height, source_width = frame.shape[:2] scale = min(width / source_width, height / source_height) scaled_width = min(width, max(1, round(source_width * scale))) scaled_height = min(height, max(1, round(source_height * scale))) scaled = cv2.resize(frame, (scaled_width, scaled_height), interpolation=cv2.INTER_AREA) canvas = np.zeros((height, width, 3), dtype=np.uint8) left = (width - scaled_width) // 2 top = (height - scaled_height) // 2 canvas[top:top + scaled_height, left:left + scaled_width] = scaled return cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) def source_frames(path: Path, width: int, height: int, fps: int, start_seconds: float, duration_seconds: float) -> list[np.ndarray]: capture = cv2.VideoCapture(str(path)) if not capture.isOpened(): raise ValueError(f"Не открывается ролик: {path}") source_fps = capture.get(cv2.CAP_PROP_FPS) count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) requested = [round((start_seconds + index / fps) * source_fps) for index in range(round(duration_seconds * fps))] if source_fps <= 0 or not requested or len(set(requested)) != len(requested) or requested[-1] >= count: capture.release() raise ValueError("Окно вне ролика или частота ролика слишком мала") selected = set(requested) found = {} try: for index in range(requested[-1] + 1): success, frame = capture.read() if not success: raise ValueError(f"Не прочитан исходный кадр {index}") if index in selected: found[index] = fit_frame(frame, width, height) finally: capture.release() return [found[index] for index in requested] def quality(reference: np.ndarray, decoded: np.ndarray) -> tuple[float, float]: difference = reference.astype(np.float32) - decoded.astype(np.float32) error = float(np.mean(difference * difference)) psnr = 10 * math.log10(255 * 255 / error) if error else 99.0 return psnr, float(np.mean(np.abs(difference))) def save_example(base: Path, reference: np.ndarray, decoded: np.ndarray) -> None: base.parent.mkdir(parents=True, exist_ok=True) Image.fromarray(reference).save(base.with_name(base.name + "_source.png")) Image.fromarray(decoded).save(base.with_name(base.name + "_decoded.png")) def jpeg_case(frames: list[np.ndarray], fps: int, example: Path) -> dict: sizes, encodes, decodes, latencies, psnrs, errors, hashes = ([] for _ in range(7)) payloads: list[bytes] = [] peak_rss = PROCESS.memory_info().rss for index, reference in enumerate(frames): started = time.perf_counter() buffer = BytesIO() Image.fromarray(reference).save(buffer, format="JPEG", quality=35, subsampling=2, optimize=False, progressive=False) encoded = buffer.getvalue() payloads.append(encoded) encoded_at = time.perf_counter() with Image.open(BytesIO(encoded)) as image: decoded = np.asarray(image.convert("RGB")).copy() decoded_at = time.perf_counter() sizes.append(len(encoded)) encodes.append(encoded_at - started) decodes.append(decoded_at - encoded_at) latencies.append(decoded_at - started) psnr, error = quality(reference, decoded) psnrs.append(psnr) errors.append(error) hashes.append(hashlib.sha256(decoded.tobytes()).hexdigest()) peak_rss = max(peak_rss, PROCESS.memory_info().rss) if index == min(fps, len(frames) - 1): save_example(example, reference, decoded) with Image.open(BytesIO(payloads[1])) as image: recovered_after_drop = np.asarray(image.convert("RGB")) recovery_exact = hashlib.sha256(recovered_after_drop.tobytes()).hexdigest() == hashes[1] return {"codec": "JPEG_INDEPENDENT", "settings": {"quality": 35, "subsampling": "4:2:0", "optimize": False, "progressive": False}, "frame_sizes_bytes": sizes, "reference_frame_size_bytes": max(sizes), "encode_time_per_frame_s": summary(encodes), "decode_time_per_frame_s": summary(decodes), "file_pipeline_latency_s": summary(latencies), "actual_encoded_fps": len(frames) / sum(encodes), "unique_decoded_frames": len(set(hashes)), "decoded_frame_count": len(hashes), "psnr_db": summary(psnrs), "mean_absolute_error": summary(errors), "peak_process_rss_bytes": peak_rss, "loss_recovery": {"dropped_frame": 0, "first_exact_frame": 1 if recovery_exact else None}, "operator_quality": "PENDING_VISUAL_REVIEW"} def recovery_after_drop(packets: list[bytes], dropped: int, fps: int, clean_hashes: dict[int, str]) -> dict: decoder = av.CodecContext.create("h264", "r") decoder.open() exact_after_loss = [] decode_errors = 0 for index, payload in enumerate(packets): if index == dropped: continue packet = av.Packet(payload) packet.pts = packet.dts = index packet.time_base = Fraction(1, fps) try: decoded_frames = decoder.decode(packet) except av.error.FFmpegError: decode_errors += 1 continue for frame in decoded_frames: if frame.pts is None or frame.pts <= dropped: continue digest = hashlib.sha256(frame.to_ndarray(format="rgb24").tobytes()).hexdigest() if clean_hashes.get(frame.pts) == digest: exact_after_loss.append(int(frame.pts)) return {"dropped_frame": dropped, "first_exact_frame": min(exact_after_loss) if exact_after_loss else None, "decoder_errors": decode_errors} def new_h264_encoder(width: int, height: int, fps: int, maxrate_bps: int) -> tuple[av.CodecContext, str]: 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 parameters = (f"keyint={fps}:min-keyint={fps}: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={maxrate_bps // 1000}") encoder.options = {"preset": "veryfast", "tune": "zerolatency", "crf": "28", "x264-params": parameters} encoder.open() return encoder, parameters def h264_case(frames: list[np.ndarray], fps: int, maxrate_bps: int, example: Path) -> dict: height, width, _ = frames[0].shape encoder, parameters = new_h264_encoder(width, height, fps, maxrate_bps) decoder = av.CodecContext.create("h264", "r") decoder.open() sizes = [0] * len(frames) encodes, decodes, latencies, psnrs, errors = ([] for _ in range(5)) packets: list[tuple[int, bytes, bool]] = [] hashes: dict[int, str] = {} submitted: dict[int, float] = {} peak_rss = PROCESS.memory_info().rss for index, reference in enumerate(frames): started = time.perf_counter() submitted[index] = started frame = av.VideoFrame.from_ndarray(reference, format="rgb24").reformat(format="yuv420p") frame.pts = index encoded_packets = encoder.encode(frame) encodes.append(time.perf_counter() - started) decode_cost = 0.0 for encoded in encoded_packets: packet_index = int(encoded.pts) payload = bytes(encoded) packets.append((packet_index, payload, bool(encoded.is_keyframe))) sizes[packet_index] += len(payload) packet = av.Packet(payload) packet.pts = packet.dts = packet_index packet.time_base = Fraction(1, fps) decoding_started = time.perf_counter() decoded_frames = decoder.decode(packet) decode_cost += time.perf_counter() - decoding_started for item in decoded_frames: decoded_index = int(item.pts) decoded = item.to_ndarray(format="rgb24") latencies.append(time.perf_counter() - submitted[decoded_index]) hashes[decoded_index] = hashlib.sha256(decoded.tobytes()).hexdigest() psnr, error = quality(frames[decoded_index], decoded) psnrs.append(psnr) errors.append(error) if decoded_index == min(fps, len(frames) - 1): save_example(example, frames[decoded_index], decoded) decodes.append(decode_cost) peak_rss = max(peak_rss, PROCESS.memory_info().rss) if encoder.encode(None) or len(packets) != len(frames) or len(hashes) != len(frames): raise RuntimeError("H.264 задержал или потерял кадры") if sorted(index for index, _, _ in packets) != list(range(len(frames))): raise RuntimeError("H.264 нарушил нумерацию кадров") if any(key != (index % fps == 0) for index, _, key in packets): raise RuntimeError("Период опорных кадров отличается от заданного") reference_sizes = [len(payload) for _, payload, key in packets if key] payloads = [payload for _, payload, _ in packets] return {"codec": "H264_LIBX264", "settings": {"container": "Annex B packet stream", "preset": "veryfast", "tune": "zerolatency", "crf": 28, "pixel_format": "yuv420p", "maxrate_bps": maxrate_bps, "vbv_buffer_bits": maxrate_bps, "gop_frames": fps, "b_frames": 0, "encoder_threads": 1, "x264_parameters": parameters, "pyav_version": av.__version__, "ffmpeg_libraries": {name: ".".join(map(str, version)) for name, version in av.library_versions.items()}}, "frame_sizes_bytes": sizes, "keyframe_sizes_bytes": reference_sizes, "reference_frame_size_bytes": max(reference_sizes), "encode_time_per_frame_s": summary(encodes), "decode_time_per_frame_s": summary(decodes), "file_pipeline_latency_s": summary(latencies), "actual_encoded_fps": len(frames) / sum(encodes), "unique_decoded_frames": len(set(hashes.values())), "decoded_frame_count": len(hashes), "psnr_db": summary(psnrs), "mean_absolute_error": summary(errors), "peak_process_rss_bytes": peak_rss, "loss_recovery": { "lost_reference": recovery_after_drop(payloads, 0, fps, hashes), "lost_delta": recovery_after_drop(payloads, 1, fps, hashes)}, "operator_quality": "PENDING_VISUAL_REVIEW"} def end_to_end_file_case(source: Path, frames: list[np.ndarray], fps: int, start_seconds: float, width: int, height: int, codec: str, maxrate_bps: int, expected_sizes: list[int]) -> dict: """Замерить чтение очередного кадра MP4 до готового декодированного RGB.""" capture = cv2.VideoCapture(str(source)) if not capture.isOpened(): raise ValueError(f"Не открывается ролик: {source}") source_fps = capture.get(cv2.CAP_PROP_FPS) requested = [round((start_seconds + index / fps) * source_fps) for index in range(len(frames))] if not capture.set(cv2.CAP_PROP_POS_FRAMES, requested[0]): capture.release() raise RuntimeError("Не удалось перейти к началу измеряемого участка") next_source_index = requested[0] encoder = decoder = None if codec == "H264_LIBX264": encoder, _ = new_h264_encoder(width, height, fps, maxrate_bps) decoder = av.CodecContext.create("h264", "r") decoder.open() latencies = [] source_costs = [] peak_rss = PROCESS.memory_info().rss try: for index, target in enumerate(requested): started = time.perf_counter() while next_source_index <= target: success, source_frame = capture.read() if not success: raise RuntimeError(f"Не прочитан кадр MP4 {next_source_index}") next_source_index += 1 reference = fit_frame(source_frame, width, height) source_costs.append(time.perf_counter() - started) if not np.array_equal(reference, frames[index]): raise RuntimeError("Кадр прямого файлового прохода отличается от исходного") if codec == "JPEG_INDEPENDENT": buffer = BytesIO() Image.fromarray(reference).save(buffer, format="JPEG", quality=35, subsampling=2, optimize=False, progressive=False) encoded = buffer.getvalue() with Image.open(BytesIO(encoded)) as image: decoded = np.asarray(image.convert("RGB")) else: frame = av.VideoFrame.from_ndarray(reference, format="rgb24").reformat( format="yuv420p") frame.pts = index packets = encoder.encode(frame) if len(packets) != 1: raise RuntimeError("Файловый H.264 проход задержал пакет") encoded = bytes(packets[0]) packet = av.Packet(encoded) packet.pts = packet.dts = index packet.time_base = Fraction(1, fps) images = decoder.decode(packet) if len(images) != 1: raise RuntimeError("Файловый H.264 проход задержал кадр") decoded = images[0].to_ndarray(format="rgb24") if len(encoded) != expected_sizes[index] or decoded.shape != reference.shape: raise RuntimeError("Файловый проход не совпал с основным кодированием") latencies.append(time.perf_counter() - started) peak_rss = max(peak_rss, PROCESS.memory_info().rss) if encoder is not None and encoder.encode(None): raise RuntimeError("H.264 сохранил отложенные пакеты после файлового прохода") finally: capture.release() return {"end_to_end_file_pipeline_latency_s": summary(latencies), "source_read_resize_time_per_frame_s": summary(source_costs), "peak_process_rss_bytes": peak_rss} def run(source: Path, output: Path, maxrate_bps: int, matrix=MATRIX, windows=DEFAULT_WINDOWS) -> dict: source_sha256 = hashlib.sha256(source.read_bytes()).hexdigest() results = [] for width, height, rates in matrix: for fps in rates: for window_name, start_seconds, duration_seconds in windows: frames = source_frames(source, width, height, fps, start_seconds, duration_seconds) source_hashes = {hashlib.sha256(frame.tobytes()).hexdigest() for frame in frames} if len(source_hashes) < len(frames) // 2: raise RuntimeError("В исходном окне слишком много повторных кадров") temporal_change = statistics.mean(float(np.mean(np.abs( later.astype(np.int16) - earlier.astype(np.int16)))) for earlier, later in zip(frames, frames[1:])) for name, operation in (("jpeg", jpeg_case), ("h264", h264_case)): suffix = f"_{maxrate_bps}" if name == "h264" else "" example = (output.parent / "examples" / f"{window_name}_{width}x{height}_{fps}_{name}{suffix}") case = (operation(frames, fps, example) if name == "jpeg" else operation(frames, fps, maxrate_bps, example)) sizes = case["frame_sizes_bytes"] direct = end_to_end_file_case( source, frames, fps, start_seconds, width, height, case["codec"], maxrate_bps, sizes) case["end_to_end_file_pipeline_latency_s"] = direct[ "end_to_end_file_pipeline_latency_s"] case["source_read_resize_time_per_frame_s"] = direct[ "source_read_resize_time_per_frame_s"] case["peak_process_rss_bytes"] = max( case["peak_process_rss_bytes"], direct["peak_process_rss_bytes"]) case.update({"resolution": [width, height], "fps": fps, "window": window_name, "source_unique_frames": len(source_hashes), "source_temporal_change_mean_absolute": temporal_change, "unique_decoded_fps": case["unique_decoded_frames"] / duration_seconds, "identical_decoded_frame_count": ( case["decoded_frame_count"] - case["unique_decoded_frames"]), "average_bitrate_bps": 8 * sum(sizes) / duration_seconds, "peak_one_second_bitrate_bps": peak_second(sizes, fps), "average_frame_size_bytes": statistics.mean(sizes), "max_frame_size_bytes": max(sizes), "radio_screen": radio_screen(sizes, fps)}) results.append(case) print(f"{width}x{height} {fps} {window_name} {case['codec']}: " f"{case['average_bitrate_bps']:.0f} бит/с", flush=True) document = { "status": "MEASURED_SOFTWARE_ONLY", "rf_tx": 0, "source": str(source), "source_sha256": source_sha256, "windows": [{"name": name, "start_seconds": start, "duration_seconds": duration} for name, start, duration in windows], "radio_budget": { "status": "MEASURED_ONE_BURST_VIDEO_EQUIVALENT", "lab066_accepted_packets": 10, "lab066_transmitted_packets": 16, "burst_seconds_including_guards": BURST_SECONDS, "video_equivalent_goodput_bps": OBSERVED_VIDEO_EQUIVALENT_BPS, "zero_loss_goodput_bps_with_5ms_gaps": FRAGMENT_BYTES * 8 * SAMPLE_RATE / (PACKET_SAMPLES + GAP_SAMPLES), "sustained_video_goodput": "NOT_ESTABLISHED", "h264_over_current_fragment_transport": "NOT_IMPLEMENTED"}, "memory_measurement": "RSS всего процесса, пик при обработке случая; случаи в одном процессе", "resize_method": "Пропорциональное уменьшение с чёрными полями, без растяжения и обрезки", "latency_measurement": "file_pipeline_latency_s: от подачи готового кадра кодировщику до RGB; end_to_end_file_pipeline_latency_s: от чтения очередного кадра MP4 с пропуском промежуточных до RGB; оба без эфира и экрана", "operator_quality_scope": "PSNR не заменяет проверку препятствий и читаемости оператором", "cases": results, } output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(document, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8") return document def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--source", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--h264-maxrate-bps", type=int, default=160_000) parser.add_argument("--window", nargs=3, action="append", metavar=("NAME", "START_S", "DURATION_S"), help="Окно ролика; параметр можно повторить") arguments = parser.parse_args() if arguments.h264_maxrate_bps < 10_000 or arguments.h264_maxrate_bps % 1000: parser.error("maxrate должен быть не меньше 10 кбит/с и кратным 1000") windows = DEFAULT_WINDOWS if arguments.window: try: windows = tuple((name, float(start), float(duration)) for name, start, duration in arguments.window) except ValueError: parser.error("START_S и DURATION_S должны быть числами") if any(start < 0 or duration <= 0 for _, start, duration in windows): parser.error("окна должны иметь неотрицательное начало и положительную длительность") run(arguments.source, arguments.output, arguments.h264_maxrate_bps, windows=windows) if __name__ == "__main__": main()