""" Lab027C. Динамическое сравнение асинхронного и синхронного BASE + ROI. Мини-лабораторная выполняет два детерминированных прохода по одному исходному видео: 1. Первый проход измеряет размеры JPEG BASE и ROI для четырёх фиксированных профилей. 2. Второй проход повторяет расписание, проверяет совпадение размеров JPEG и создаёт preview с окончательными измеренными битрейтами. Асинхронный профиль хранит отдельные состояния BASE и ROI. В трёх синхронных профилях обе части формируются из одного исходного кадра и атомарно заменяют отображаемый составной кадр. JPEG кодируются и декодируются только в памяти. Скрипт не изменяет существующие лабораторные и их результаты. """ from __future__ import annotations import csv from dataclasses import dataclass from pathlib import Path import cv2 import numpy as np SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4") OUTPUT_DIRECTORY = Path("data/processed/lab027c") CSV_PATH = OUTPUT_DIRECTORY / "lab027c_preview_profiles.csv" REPORT_PATH = OUTPUT_DIRECTORY / "lab027c_preview_report.txt" PREVIEW_DIRECTORY = Path("data/raw/lab027c_previews") MP4_PREVIEW_PATH = ( PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.mp4" ) AVI_PREVIEW_PATH = ( PREVIEW_DIRECTORY / "lab027c_synchronous_roi_preview.avi" ) UPDATE_MODE_ASYNCHRONOUS = "asynchronous" UPDATE_MODE_SYNCHRONOUS = "synchronous" OUTPUT_FPS = 30.0 PANEL_WIDTH = 640 PANEL_HEIGHT = 360 OUTPUT_WIDTH = PANEL_WIDTH * 2 OUTPUT_HEIGHT = PANEL_HEIGHT * 2 ROI_X_MIN = 0.20 ROI_X_MAX = 0.80 ROI_Y_MIN = 0.42 ROI_Y_MAX = 1.00 FRAME_TIME_EPSILON_SECONDS = 1e-9 NEW_LABEL_DURATION_SECONDS = 0.15 SYNCHRONOUS_PERIOD_SECONDS = 0.5 NOT_APPLICABLE = "N/A" CSV_FIELD_NAMES = [ "profile_name", "update_mode", "base_width", "base_height", "base_fps", "base_quality", "roi_width", "roi_height", "roi_fps", "roi_quality", "source_duration_s", "selected_base_frames", "selected_roi_frames", "synchronized_composite_frames", "total_base_bytes", "total_roi_bytes", "total_payload_bytes", "mean_base_frame_bytes", "mean_roi_frame_bytes", "mean_composite_frame_bytes", "p95_composite_frame_bytes", "max_composite_frame_bytes", "base_bitrate_kbps", "roi_bitrate_kbps", "total_payload_bitrate_kbps", "timestamp_mismatch_count", "frame_id_mismatch_count", ] @dataclass(frozen=True) class PreviewProfile: """ Описывает один из четырёх фиксированных профилей Lab027C. """ profile_name: str update_mode: str base_width: int base_height: int base_fps: float base_quality: int roi_width: int roi_height: int roi_fps: float roi_quality: int @dataclass class AsyncState: """ Хранит независимые временные состояния асинхронных BASE и ROI. """ latest_base: np.ndarray | None = None latest_roi: np.ndarray | None = None next_base_time: float = 0.0 next_roi_time: float = 0.0 last_base_update_time: float = 0.0 last_roi_update_time: float = 0.0 base_frame_id: int = -1 roi_frame_id: int = -1 base_source_frame_index: int = -1 roi_source_frame_index: int = -1 base_timestamp: float = 0.0 roi_timestamp: float = 0.0 base_update_count: int = 0 roi_update_count: int = 0 @dataclass class SyncState: """ Хранит единое атомарное состояние синхронного составного кадра. """ latest_base: np.ndarray | None = None latest_roi: np.ndarray | None = None next_composite_time: float = 0.0 last_composite_update_time: float = 0.0 composite_frame_id: int = -1 source_frame_index: int = -1 timestamp: float = 0.0 base_update_count: int = 0 roi_update_count: int = 0 synchronized_update_count: int = 0 timestamp_mismatch_count: int = 0 frame_id_mismatch_count: int = 0 @dataclass(frozen=True) class ProfileStatistics: """ Хранит полные измеренные параметры одного preview-профиля. """ profile_name: str update_mode: str base_width: int base_height: int base_fps: float base_quality: int roi_width: int roi_height: int roi_fps: float roi_quality: int source_duration_s: float selected_base_frames: int selected_roi_frames: int synchronized_composite_frames: int | None total_base_bytes: int total_roi_bytes: int total_payload_bytes: int mean_base_frame_bytes: float mean_roi_frame_bytes: float mean_composite_frame_bytes: float p95_composite_frame_bytes: float max_composite_frame_bytes: int base_bitrate_kbps: float roi_bitrate_kbps: float total_payload_bitrate_kbps: float timestamp_mismatch_count: int | None frame_id_mismatch_count: int | None Measurements = dict[str, dict[str, list[int]]] ProfileState = AsyncState | SyncState def read_video_metadata( source_path: Path, ) -> tuple[int, int, float, int, float, int]: """ Читает и проверяет параметры исходного видео. """ if not source_path.exists(): raise RuntimeError( f"Исходный видеофайл отсутствует: {source_path}" ) capture = cv2.VideoCapture(str(source_path)) if not capture.isOpened(): raise RuntimeError( f"OpenCV не смог открыть видео: {source_path}" ) try: width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) fps = float(capture.get(cv2.CAP_PROP_FPS)) frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) finally: capture.release() if width <= 0 or height <= 0: raise RuntimeError( "OpenCV вернул некорректное разрешение видео." ) if fps <= 0.0: raise RuntimeError("FPS исходного видео равен нулю.") if frame_count <= 0: raise RuntimeError("Число кадров исходного видео равно нулю.") duration_seconds = frame_count / fps file_size_bytes = source_path.stat().st_size if duration_seconds <= 0.0 or file_size_bytes <= 0: raise RuntimeError( "Длительность или размер исходного видео некорректны." ) return ( width, height, fps, frame_count, duration_seconds, file_size_bytes, ) def build_profiles() -> list[PreviewProfile]: """ Создаёт ровно четыре согласованных профиля Lab027C. """ profiles = [ PreviewProfile( profile_name="async_reference", update_mode=UPDATE_MODE_ASYNCHRONOUS, base_width=240, base_height=135, base_fps=1.0, base_quality=25, roi_width=320, roi_height=180, roi_fps=2.0, roi_quality=35, ), PreviewProfile( profile_name="sync_base160_q25_roi320_q35", update_mode=UPDATE_MODE_SYNCHRONOUS, base_width=160, base_height=90, base_fps=2.0, base_quality=25, roi_width=320, roi_height=180, roi_fps=2.0, roi_quality=35, ), PreviewProfile( profile_name="sync_base240_q20_roi320_q30", update_mode=UPDATE_MODE_SYNCHRONOUS, base_width=240, base_height=135, base_fps=2.0, base_quality=20, roi_width=320, roi_height=180, roi_fps=2.0, roi_quality=30, ), PreviewProfile( profile_name="sync_base240_q25_roi320_q35", update_mode=UPDATE_MODE_SYNCHRONOUS, base_width=240, base_height=135, base_fps=2.0, base_quality=25, roi_width=320, roi_height=180, roi_fps=2.0, roi_quality=35, ), ] if len(profiles) != 4: raise RuntimeError( "Lab027C должна содержать ровно четыре профиля." ) if len({profile.profile_name for profile in profiles}) != 4: raise RuntimeError("Имена профилей Lab027C не уникальны.") return profiles def normalized_roi_to_pixels( width: int, height: int, ) -> tuple[int, int, int, int]: """ Переводит нормализованные координаты ROI Lab027 в пиксели. """ if width <= 0 or height <= 0: raise ValueError("Размер кадра должен быть положительным.") x_min = int(round(width * ROI_X_MIN)) x_max = int(round(width * ROI_X_MAX)) y_min = int(round(height * ROI_Y_MIN)) y_max = int(round(height * ROI_Y_MAX)) x_min = min(max(x_min, 0), width - 1) x_max = min(max(x_max, x_min + 1), width) y_min = min(max(y_min, 0), height - 1) y_max = min(max(y_max, y_min + 1), height) return x_min, y_min, x_max, y_max def should_update( current_time: float, next_update_time: float, ) -> bool: """ Проверяет наступление времени очередного обновления. """ return ( current_time + FRAME_TIME_EPSILON_SECONDS >= next_update_time ) def encode_decode_jpeg( gray_frame: np.ndarray, jpeg_quality: int, ) -> tuple[int, np.ndarray]: """ Кодирует grayscale JPEG в памяти и декодирует его обратно. """ if gray_frame.ndim != 2: raise RuntimeError( "JPEG Lab027C должен получать grayscale-кадр." ) encoding_ok, encoded = cv2.imencode( ".jpg", gray_frame, [cv2.IMWRITE_JPEG_QUALITY, jpeg_quality], ) if not encoding_ok or encoded is None or encoded.size == 0: raise RuntimeError("OpenCV не смог закодировать JPEG.") decoded = cv2.imdecode( encoded, cv2.IMREAD_GRAYSCALE, ) if decoded is None or decoded.shape != gray_frame.shape: raise RuntimeError( "Декодированный JPEG имеет некорректный размер." ) return int(encoded.size), decoded def encode_base( source_frame: np.ndarray, profile: PreviewProfile, ) -> tuple[int, np.ndarray]: """ Формирует JPEG BASE из текущего исходного кадра. """ source_gray = cv2.cvtColor( source_frame, cv2.COLOR_BGR2GRAY, ) resized = cv2.resize( source_gray, (profile.base_width, profile.base_height), interpolation=cv2.INTER_AREA, ) return encode_decode_jpeg( resized, profile.base_quality, ) def encode_roi( source_frame: np.ndarray, source_roi: tuple[int, int, int, int], profile: PreviewProfile, ) -> tuple[int, np.ndarray]: """ Формирует JPEG ROI из того же текущего исходного кадра. """ x_min, y_min, x_max, y_max = source_roi roi_bgr = source_frame[y_min:y_max, x_min:x_max] if roi_bgr.size == 0: raise RuntimeError("Вырезана пустая ROI.") roi_gray = cv2.cvtColor( roi_bgr, cv2.COLOR_BGR2GRAY, ) resized = cv2.resize( roi_gray, (profile.roi_width, profile.roi_height), interpolation=cv2.INTER_AREA, ) return encode_decode_jpeg( resized, profile.roi_quality, ) def create_measurements( profiles: list[PreviewProfile], ) -> Measurements: """ Создаёт пустые списки размеров для одного прохода. """ return { profile.profile_name: { "base": [], "roi": [], "composite": [], } for profile in profiles } def create_states( profiles: list[PreviewProfile], ) -> list[ProfileState]: """ Создаёт чистые временные состояния всех профилей. """ states: list[ProfileState] = [] for profile in profiles: if profile.update_mode == UPDATE_MODE_ASYNCHRONOUS: states.append(AsyncState()) elif profile.update_mode == UPDATE_MODE_SYNCHRONOUS: states.append(SyncState()) else: raise RuntimeError( f"Неизвестный режим: {profile.update_mode}" ) return states def update_async_state( source_frame: np.ndarray, source_roi: tuple[int, int, int, int], source_frame_index: int, current_time: float, profile: PreviewProfile, state: AsyncState, measurements: dict[str, list[int]], ) -> tuple[bool, bool]: """ Независимо обновляет BASE и ROI асинхронного reference. """ base_updated = False roi_updated = False event_payload_bytes = 0 if should_update(current_time, state.next_base_time): base_size, decoded_base = encode_base( source_frame, profile, ) state.latest_base = decoded_base state.base_frame_id += 1 state.base_source_frame_index = source_frame_index state.base_timestamp = current_time state.last_base_update_time = current_time state.next_base_time += 1.0 / profile.base_fps state.base_update_count += 1 measurements["base"].append(base_size) event_payload_bytes += base_size base_updated = True if should_update(current_time, state.next_roi_time): roi_size, decoded_roi = encode_roi( source_frame, source_roi, profile, ) state.latest_roi = decoded_roi state.roi_frame_id += 1 state.roi_source_frame_index = source_frame_index state.roi_timestamp = current_time state.last_roi_update_time = current_time state.next_roi_time += 1.0 / profile.roi_fps state.roi_update_count += 1 measurements["roi"].append(roi_size) event_payload_bytes += roi_size roi_updated = True if event_payload_bytes > 0: measurements["composite"].append(event_payload_bytes) return base_updated, roi_updated def update_sync_state( source_frame: np.ndarray, source_roi: tuple[int, int, int, int], source_frame_index: int, current_time: float, profile: PreviewProfile, state: SyncState, measurements: dict[str, list[int]], ) -> bool: """ Атомарно обновляет обе части синхронного составного кадра. """ if not should_update( current_time, state.next_composite_time, ): return False next_composite_id = state.composite_frame_id + 1 # Метаданные обеих частей назначаются до кодирования из одного # и того же исходного кадра. base_source_frame_index = source_frame_index roi_source_frame_index = source_frame_index base_timestamp = current_time roi_timestamp = current_time base_composite_id = next_composite_id roi_composite_id = next_composite_id base_size, decoded_base = encode_base( source_frame, profile, ) roi_size, decoded_roi = encode_roi( source_frame, source_roi, profile, ) if base_timestamp != roi_timestamp: state.timestamp_mismatch_count += 1 if ( base_source_frame_index != roi_source_frame_index or base_composite_id != roi_composite_id ): state.frame_id_mismatch_count += 1 # Отображаемое состояние заменяется только после готовности # обоих декодированных JPEG. state.latest_base = decoded_base state.latest_roi = decoded_roi state.composite_frame_id = next_composite_id state.source_frame_index = source_frame_index state.timestamp = current_time state.last_composite_update_time = current_time state.next_composite_time += SYNCHRONOUS_PERIOD_SECONDS state.base_update_count += 1 state.roi_update_count += 1 state.synchronized_update_count += 1 measurements["base"].append(base_size) measurements["roi"].append(roi_size) measurements["composite"].append(base_size + roi_size) return True def process_first_pass( source_path: Path, profiles: list[PreviewProfile], source_width: int, source_height: int, source_fps: float, expected_frame_count: int, ) -> tuple[Measurements, list[ProfileState]]: """ Измеряет размеры JPEG без создания preview-видео. """ measurements = create_measurements(profiles) states = create_states(profiles) source_roi = normalized_roi_to_pixels( source_width, source_height, ) capture = cv2.VideoCapture(str(source_path)) if not capture.isOpened(): raise RuntimeError( f"OpenCV не смог открыть видео: {source_path}" ) frame_index = 0 try: while True: frame_read, source_frame = capture.read() if not frame_read: break if source_frame is None: raise RuntimeError( f"Получен пустой кадр {frame_index}." ) if ( source_frame.shape[1] != source_width or source_frame.shape[0] != source_height ): raise RuntimeError( "Размер кадра отличается от метаданных." ) current_time = frame_index / source_fps for profile, state in zip(profiles, states): profile_measurements = measurements[ profile.profile_name ] if isinstance(state, AsyncState): update_async_state( source_frame, source_roi, frame_index, current_time, profile, state, profile_measurements, ) else: update_sync_state( source_frame, source_roi, frame_index, current_time, profile, state, profile_measurements, ) frame_index += 1 if ( frame_index % 100 == 0 or frame_index == expected_frame_count ): print( f" First pass frames: " f"{frame_index}/{expected_frame_count}" ) finally: capture.release() if frame_index != expected_frame_count: raise RuntimeError( "Первый проход прочитал некорректное число кадров." ) return measurements, states def calculate_statistics( profiles: list[PreviewProfile], measurements: Measurements, states: list[ProfileState], source_duration_seconds: float, ) -> list[ProfileStatistics]: """ Рассчитывает итоговые фактические битрейты и размеры обновлений. """ statistics: list[ProfileStatistics] = [] for profile, state in zip(profiles, states): profile_measurements = measurements[ profile.profile_name ] base_sizes = profile_measurements["base"] roi_sizes = profile_measurements["roi"] composite_sizes = profile_measurements["composite"] if not base_sizes or not roi_sizes or not composite_sizes: raise RuntimeError( f"Пустые измерения: {profile.profile_name}" ) total_base_bytes = int(sum(base_sizes)) total_roi_bytes = int(sum(roi_sizes)) total_payload_bytes = ( total_base_bytes + total_roi_bytes ) base_bitrate_kbps = ( total_base_bytes * 8.0 / source_duration_seconds / 1000.0 ) roi_bitrate_kbps = ( total_roi_bytes * 8.0 / source_duration_seconds / 1000.0 ) total_payload_bitrate_kbps = ( total_payload_bytes * 8.0 / source_duration_seconds / 1000.0 ) if not np.isclose( total_payload_bitrate_kbps, base_bitrate_kbps + roi_bitrate_kbps, rtol=0.0, atol=1e-12, ): raise RuntimeError( "Суммарный битрейт не равен BASE + ROI." ) if isinstance(state, AsyncState): synchronized_frames = None timestamp_mismatch_count = None frame_id_mismatch_count = None else: synchronized_frames = ( state.synchronized_update_count ) timestamp_mismatch_count = ( state.timestamp_mismatch_count ) frame_id_mismatch_count = ( state.frame_id_mismatch_count ) if ( len(base_sizes) != len(roi_sizes) or len(base_sizes) != synchronized_frames ): raise RuntimeError( "Число синхронных обновлений не совпало." ) if ( timestamp_mismatch_count != 0 or frame_id_mismatch_count != 0 ): raise RuntimeError( "Обнаружено рассогласование sync-профиля." ) composite_array = np.asarray( composite_sizes, dtype=np.float64, ) statistics.append( ProfileStatistics( profile_name=profile.profile_name, update_mode=profile.update_mode, base_width=profile.base_width, base_height=profile.base_height, base_fps=profile.base_fps, base_quality=profile.base_quality, roi_width=profile.roi_width, roi_height=profile.roi_height, roi_fps=profile.roi_fps, roi_quality=profile.roi_quality, source_duration_s=source_duration_seconds, selected_base_frames=len(base_sizes), selected_roi_frames=len(roi_sizes), synchronized_composite_frames=( synchronized_frames ), total_base_bytes=total_base_bytes, total_roi_bytes=total_roi_bytes, total_payload_bytes=total_payload_bytes, mean_base_frame_bytes=float( np.mean( np.asarray( base_sizes, dtype=np.float64, ) ) ), mean_roi_frame_bytes=float( np.mean( np.asarray( roi_sizes, dtype=np.float64, ) ) ), mean_composite_frame_bytes=float( np.mean(composite_array) ), p95_composite_frame_bytes=float( np.percentile(composite_array, 95) ), max_composite_frame_bytes=int( np.max(composite_array) ), base_bitrate_kbps=base_bitrate_kbps, roi_bitrate_kbps=roi_bitrate_kbps, total_payload_bitrate_kbps=( total_payload_bitrate_kbps ), timestamp_mismatch_count=( timestamp_mismatch_count ), frame_id_mismatch_count=( frame_id_mismatch_count ), ) ) if len(statistics) != 4: raise RuntimeError( "Создано не четыре результата Lab027C." ) return statistics def reconstruct_panel( latest_base: np.ndarray | None, latest_roi: np.ndarray | None, panel_roi: tuple[int, int, int, int], ) -> np.ndarray: """ Восстанавливает составную grayscale-панель размером 640x360. """ if latest_base is None or latest_roi is None: raise RuntimeError( "BASE или ROI ещё не инициализированы." ) reconstructed = cv2.resize( latest_base, (PANEL_WIDTH, PANEL_HEIGHT), interpolation=cv2.INTER_LINEAR, ) x_min, y_min, x_max, y_max = panel_roi resized_roi = cv2.resize( latest_roi, (x_max - x_min, y_max - y_min), interpolation=cv2.INTER_LINEAR, ) reconstructed[y_min:y_max, x_min:x_max] = resized_roi return reconstructed def draw_text_line( image: np.ndarray, text: str, y_position: int, color: tuple[int, int, int] = (255, 255, 255), ) -> None: """ Рисует одну ASCII-строку служебной информации. """ cv2.putText( image, text, (9, y_position), cv2.FONT_HERSHEY_SIMPLEX, 0.41, color, 1, cv2.LINE_AA, ) def prepare_panel_background( reconstructed_gray: np.ndarray, panel_roi: tuple[int, int, int, int], ) -> np.ndarray: """ Преобразует панель в BGR, рисует ROI и фон подписей. """ panel = cv2.cvtColor( reconstructed_gray, cv2.COLOR_GRAY2BGR, ) cv2.rectangle( panel, (panel_roi[0], panel_roi[1]), (panel_roi[2] - 1, panel_roi[3] - 1), (0, 255, 255), 2, ) overlay = panel.copy() cv2.rectangle( overlay, (0, 0), (PANEL_WIDTH - 1, 158), (0, 0, 0), thickness=-1, ) cv2.addWeighted( overlay, 0.74, panel, 0.26, 0.0, panel, ) return panel def draw_async_information( reconstructed_gray: np.ndarray, profile: PreviewProfile, statistics: ProfileStatistics, state: AsyncState, current_time: float, panel_roi: tuple[int, int, int, int], ) -> np.ndarray: """ Добавляет отдельные ID, source frame и возраст BASE/ROI. """ panel = prepare_panel_background( reconstructed_gray, panel_roi, ) base_age = max( 0.0, current_time - state.last_base_update_time, ) roi_age = max( 0.0, current_time - state.last_roi_update_time, ) draw_text_line( panel, "ASYNCHRONOUS | async_reference", 18, ) draw_text_line( panel, ( f"BASE {profile.base_width}x{profile.base_height} " f"{profile.base_fps:g}fps Q{profile.base_quality}" ), 37, ) draw_text_line( panel, ( f"ROI {profile.roi_width}x{profile.roi_height} " f"{profile.roi_fps:g}fps Q{profile.roi_quality}" ), 56, ) draw_text_line( panel, ( f"payload={statistics.total_payload_bitrate_kbps:.3f} " f"kbps | video t={current_time:.3f}s" ), 75, ) draw_text_line( panel, ( f"BASE ID={state.base_frame_id} " f"src={state.base_source_frame_index} " f"age={base_age:.3f}s" ), 94, ) draw_text_line( panel, ( f"ROI ID={state.roi_frame_id} " f"src={state.roi_source_frame_index} " f"age={roi_age:.3f}s" ), 113, ) update_labels: list[str] = [] if base_age <= NEW_LABEL_DURATION_SECONDS: update_labels.append("NEW BASE") if roi_age <= NEW_LABEL_DURATION_SECONDS: update_labels.append("NEW ROI") if update_labels: draw_text_line( panel, " | ".join(update_labels), 137, color=(0, 255, 0), ) return panel def draw_sync_information( reconstructed_gray: np.ndarray, profile: PreviewProfile, statistics: ProfileStatistics, state: SyncState, current_time: float, panel_roi: tuple[int, int, int, int], ) -> np.ndarray: """ Добавляет единые ID, source frame, timestamp и возраст composite. """ panel = prepare_panel_background( reconstructed_gray, panel_roi, ) composite_age = max( 0.0, current_time - state.last_composite_update_time, ) draw_text_line( panel, f"SYNCHRONOUS | {profile.profile_name}", 18, ) draw_text_line( panel, ( f"BASE {profile.base_width}x{profile.base_height} " f"2fps Q{profile.base_quality}" ), 37, ) draw_text_line( panel, ( f"ROI {profile.roi_width}x{profile.roi_height} " f"2fps Q{profile.roi_quality}" ), 56, ) draw_text_line( panel, ( f"payload={statistics.total_payload_bitrate_kbps:.3f} " f"kbps | video t={current_time:.3f}s" ), 75, ) draw_text_line( panel, ( f"COMPOSITE ID={state.composite_frame_id} " f"source frame={state.source_frame_index}" ), 94, ) draw_text_line( panel, ( f"timestamp={state.timestamp:.3f}s " f"age={composite_age:.3f}s" ), 113, ) if composite_age <= NEW_LABEL_DURATION_SECONDS: draw_text_line( panel, "NEW COMPOSITE", 137, color=(0, 255, 0), ) return panel def compose_grid(panels: list[np.ndarray]) -> np.ndarray: """ Объединяет четыре панели в сетку 2x2 размером 1280x720. """ if len(panels) != 4: raise RuntimeError( "Для preview требуется ровно четыре панели." ) for panel in panels: if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3): raise RuntimeError( f"Некорректный размер панели: {panel.shape}" ) grid = np.vstack( ( np.hstack((panels[0], panels[1])), np.hstack((panels[2], panels[3])), ) ) if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3): raise RuntimeError( f"Некорректный размер сетки: {grid.shape}" ) return grid def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]: """ Открывает MP4/mp4v writer либо разрешённый fallback AVI/MJPG. """ mp4_writer = cv2.VideoWriter( str(MP4_PREVIEW_PATH), cv2.VideoWriter_fourcc(*"mp4v"), OUTPUT_FPS, (OUTPUT_WIDTH, OUTPUT_HEIGHT), True, ) if mp4_writer.isOpened(): return mp4_writer, MP4_PREVIEW_PATH, False mp4_writer.release() if MP4_PREVIEW_PATH.exists(): MP4_PREVIEW_PATH.unlink() avi_writer = cv2.VideoWriter( str(AVI_PREVIEW_PATH), cv2.VideoWriter_fourcc(*"MJPG"), OUTPUT_FPS, (OUTPUT_WIDTH, OUTPUT_HEIGHT), True, ) if not avi_writer.isOpened(): avi_writer.release() if AVI_PREVIEW_PATH.exists(): AVI_PREVIEW_PATH.unlink() raise RuntimeError( "OpenCV не смог открыть MP4 или AVI writer." ) return avi_writer, AVI_PREVIEW_PATH, True def write_preview( source_path: Path, profiles: list[PreviewProfile], statistics: list[ProfileStatistics], source_width: int, source_height: int, source_fps: float, expected_frame_count: int, ) -> tuple[ Path, bool, int, Measurements, list[ProfileState], ]: """ Выполняет второй проход и записывает preview с итоговым bitrate. """ PREVIEW_DIRECTORY.mkdir( parents=True, exist_ok=True, ) statistics_by_name = { item.profile_name: item for item in statistics } measurements = create_measurements(profiles) states = create_states(profiles) source_roi = normalized_roi_to_pixels( source_width, source_height, ) panel_roi = normalized_roi_to_pixels( PANEL_WIDTH, PANEL_HEIGHT, ) capture = cv2.VideoCapture(str(source_path)) if not capture.isOpened(): raise RuntimeError( f"OpenCV не смог открыть видео: {source_path}" ) writer, preview_path, fallback_used = open_preview_writer() frame_index = 0 try: while True: frame_read, source_frame = capture.read() if not frame_read: break if source_frame is None: raise RuntimeError( f"Получен пустой кадр {frame_index}." ) current_time = frame_index / source_fps panels: list[np.ndarray] = [] for profile, state in zip(profiles, states): profile_measurements = measurements[ profile.profile_name ] profile_statistics = statistics_by_name[ profile.profile_name ] if isinstance(state, AsyncState): update_async_state( source_frame, source_roi, frame_index, current_time, profile, state, profile_measurements, ) reconstructed = reconstruct_panel( state.latest_base, state.latest_roi, panel_roi, ) panels.append( draw_async_information( reconstructed, profile, profile_statistics, state, current_time, panel_roi, ) ) else: update_sync_state( source_frame, source_roi, frame_index, current_time, profile, state, profile_measurements, ) reconstructed = reconstruct_panel( state.latest_base, state.latest_roi, panel_roi, ) panels.append( draw_sync_information( reconstructed, profile, profile_statistics, state, current_time, panel_roi, ) ) writer.write(compose_grid(panels)) frame_index += 1 if ( frame_index % 100 == 0 or frame_index == expected_frame_count ): print( f" Second pass frames: " f"{frame_index}/{expected_frame_count}" ) except Exception: capture.release() writer.release() if preview_path.exists(): preview_path.unlink() raise finally: capture.release() writer.release() if frame_index != expected_frame_count: if preview_path.exists(): preview_path.unlink() raise RuntimeError( "Второй проход записал некорректное число кадров." ) return ( preview_path, fallback_used, frame_index, measurements, states, ) def compare_passes( profiles: list[PreviewProfile], first_measurements: Measurements, second_measurements: Measurements, first_states: list[ProfileState], second_states: list[ProfileState], ) -> None: """ Проверяет точное совпадение размеров JPEG и числа обновлений. """ for profile, first_state, second_state in zip( profiles, first_states, second_states, ): profile_name = profile.profile_name for stream_name in ["base", "roi", "composite"]: if ( first_measurements[profile_name][stream_name] != second_measurements[profile_name][stream_name] ): raise RuntimeError( "Размеры JPEG двух проходов не совпали: " f"{profile_name}, {stream_name}." ) if isinstance(first_state, AsyncState): if not isinstance(second_state, AsyncState): raise RuntimeError( "Тип состояния async изменился между проходами." ) if ( first_state.base_update_count != second_state.base_update_count or first_state.roi_update_count != second_state.roi_update_count ): raise RuntimeError( "Число async-обновлений между проходами различно." ) else: if not isinstance(second_state, SyncState): raise RuntimeError( "Тип состояния sync изменился между проходами." ) if ( first_state.synchronized_update_count != second_state.synchronized_update_count or first_state.timestamp_mismatch_count != second_state.timestamp_mismatch_count or first_state.frame_id_mismatch_count != second_state.frame_id_mismatch_count ): raise RuntimeError( "Sync-проверка двух проходов не совпала." ) def optional_integer_text(value: int | None) -> str: """ Представляет целое значение либо N/A для CSV и отчёта. """ return NOT_APPLICABLE if value is None else str(value) def save_csv(statistics: list[ProfileStatistics]) -> None: """ Сохраняет четыре полные строки измерений в UTF-8 CSV. """ with CSV_PATH.open( "w", encoding="utf-8", newline="", ) as csv_file: writer = csv.DictWriter( csv_file, fieldnames=CSV_FIELD_NAMES, ) writer.writeheader() for item in statistics: writer.writerow( { "profile_name": item.profile_name, "update_mode": item.update_mode, "base_width": item.base_width, "base_height": item.base_height, "base_fps": f"{item.base_fps:.6f}", "base_quality": item.base_quality, "roi_width": item.roi_width, "roi_height": item.roi_height, "roi_fps": f"{item.roi_fps:.6f}", "roi_quality": item.roi_quality, "source_duration_s": ( f"{item.source_duration_s:.6f}" ), "selected_base_frames": ( item.selected_base_frames ), "selected_roi_frames": ( item.selected_roi_frames ), "synchronized_composite_frames": ( optional_integer_text( item.synchronized_composite_frames ) ), "total_base_bytes": item.total_base_bytes, "total_roi_bytes": item.total_roi_bytes, "total_payload_bytes": ( item.total_payload_bytes ), "mean_base_frame_bytes": ( f"{item.mean_base_frame_bytes:.3f}" ), "mean_roi_frame_bytes": ( f"{item.mean_roi_frame_bytes:.3f}" ), "mean_composite_frame_bytes": ( f"{item.mean_composite_frame_bytes:.3f}" ), "p95_composite_frame_bytes": ( f"{item.p95_composite_frame_bytes:.3f}" ), "max_composite_frame_bytes": ( item.max_composite_frame_bytes ), "base_bitrate_kbps": ( f"{item.base_bitrate_kbps:.6f}" ), "roi_bitrate_kbps": ( f"{item.roi_bitrate_kbps:.6f}" ), "total_payload_bitrate_kbps": ( f"{item.total_payload_bitrate_kbps:.6f}" ), "timestamp_mismatch_count": ( optional_integer_text( item.timestamp_mismatch_count ) ), "frame_id_mismatch_count": ( optional_integer_text( item.frame_id_mismatch_count ) ), } ) def format_statistics_line(item: ProfileStatistics) -> str: """ Формирует полную строку профиля для текстового отчёта. """ return ( f"{item.profile_name}: mode={item.update_mode}, " f"BASE={item.base_width}x{item.base_height}, " f"{item.base_fps:.3f} fps, Q{item.base_quality}, " f"base_frames={item.selected_base_frames}, " f"base_bytes={item.total_base_bytes}, " f"base_bitrate={item.base_bitrate_kbps:.6f} kbit/s; " f"ROI={item.roi_width}x{item.roi_height}, " f"{item.roi_fps:.3f} fps, Q{item.roi_quality}, " f"roi_frames={item.selected_roi_frames}, " f"roi_bytes={item.total_roi_bytes}, " f"roi_bitrate={item.roi_bitrate_kbps:.6f} kbit/s; " f"total_bytes={item.total_payload_bytes}, " f"total_bitrate=" f"{item.total_payload_bitrate_kbps:.6f} kbit/s, " f"sync_frames=" f"{optional_integer_text(item.synchronized_composite_frames)}, " f"timestamp_mismatch=" f"{optional_integer_text(item.timestamp_mismatch_count)}, " f"frame_id_mismatch=" f"{optional_integer_text(item.frame_id_mismatch_count)}" ) def write_report( statistics: list[ProfileStatistics], source_width: int, source_height: int, source_fps: float, source_frame_count: int, source_duration_seconds: float, preview_path: Path, fallback_used: bool, ) -> None: """ Сохраняет краткий UTF-8 отчёт без автоматического выбора профиля. """ source_roi = normalized_roi_to_pixels( source_width, source_height, ) lines = [ "Lab027C. Динамическое сравнение асинхронного " "и синхронного BASE + ROI", "", "Цель: визуально сравнить ранее выбранный асинхронный " "профиль BASE 1 fps + ROI 2 fps с тремя синхронными " "профилями BASE 2 fps + ROI 2 fps.", "", f"Исходное видео: {SOURCE_VIDEO_PATH}", f"Исходное разрешение: {source_width}x{source_height}", f"Исходный FPS: {source_fps:.6f}", f"Число кадров: {source_frame_count}", f"Длительность: {source_duration_seconds:.6f} с", "ROI: " f"x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, " f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; " f"пиксели x={source_roi[0]}...{source_roi[2]}, " f"y={source_roi[1]}...{source_roi[3]}.", "", "Результат ручной оценки предыдущей Lab027:", "- пользователь выбрал вариант B;", "- граница ROI не мешает;", "- обновление BASE один раз в секунду мешает;", "- рассинхронное движение BASE и ROI мешает;", "- принято решение сравнить с синхронным обновлением 2 fps.", "", "async_reference: BASE и ROI обновляются независимо. " "Единого logical_frame_id нет; используются отдельные " "BASE ID и ROI ID. Mismatch для этого режима неприменим.", "Синхронные профили: BASE и ROI формируются из одного " "source frame, получают единые composite ID и timestamp " "и атомарно заменяют отображаемый составной кадр.", "", "Фактические результаты четырёх профилей:", ] lines.extend( format_statistics_line(item) for item in statistics ) lines.extend( [ "", "Количество обновлений:", ] ) for item in statistics: lines.append( f"{item.profile_name}: " f"BASE={item.selected_base_frames}, " f"ROI={item.selected_roi_frames}, " "synchronized=" f"{optional_integer_text(item.synchronized_composite_frames)}." ) lines.extend( [ "", "Mismatch-проверка:", ] ) for item in statistics: lines.append( f"{item.profile_name}: timestamp=" f"{optional_integer_text(item.timestamp_mismatch_count)}, " "frame_id=" f"{optional_integer_text(item.frame_id_mismatch_count)}." ) lines.extend( [ "", f"Preview: {preview_path}", "Формат: " + ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."), "Радиопротокол, CRC, FEC, фрагментация и служебный " "трафик пока не учитываются.", "Программа не выбирает лучший профиль и не объявляет " "режим безопасным автоматически.", "Следующий шаг: ручной выбор пользователем после " "просмотра preview-видео.", "", ] ) REPORT_PATH.write_text( "\n".join(lines), encoding="utf-8", ) def read_frame_at( capture: cv2.VideoCapture, frame_index: int, ) -> tuple[bool, tuple[int, ...] | None]: """ Читает один контрольный кадр preview. """ capture.set( cv2.CAP_PROP_POS_FRAMES, frame_index, ) frame_read, frame = capture.read() if not frame_read or frame is None: return False, None return True, frame.shape def verify_preview( preview_path: Path, source_frame_count: int, source_duration_seconds: float, ) -> tuple[ int, int, float, int, float, tuple[bool, tuple[int, ...] | None], tuple[bool, tuple[int, ...] | None], tuple[bool, tuple[int, ...] | None], ]: """ Проверяет preview и читает первый, средний и последний кадры. """ if not preview_path.exists() or preview_path.stat().st_size <= 0: raise RuntimeError("Preview отсутствует или имеет нулевой размер.") capture = cv2.VideoCapture(str(preview_path)) if not capture.isOpened(): raise RuntimeError( f"OpenCV не смог открыть preview: {preview_path}" ) try: width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) fps = float(capture.get(cv2.CAP_PROP_FPS)) frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT)) if fps <= 0.0: raise RuntimeError("FPS preview равен нулю.") duration_seconds = frame_count / fps first_frame = read_frame_at(capture, 0) middle_frame = read_frame_at( capture, frame_count // 2, ) last_frame = read_frame_at( capture, frame_count - 1, ) finally: capture.release() if (width, height) != (OUTPUT_WIDTH, OUTPUT_HEIGHT): raise RuntimeError( f"Некорректное разрешение preview: {width}x{height}." ) if frame_count != source_frame_count: raise RuntimeError( "Число кадров preview не совпало с исходным." ) if ( abs(duration_seconds - source_duration_seconds) > 1.0 / fps + FRAME_TIME_EPSILON_SECONDS ): raise RuntimeError( "Длительность preview отличается более чем на один кадр." ) if not all( frame_result[0] for frame_result in [ first_frame, middle_frame, last_frame, ] ): raise RuntimeError( "Не удалось прочитать один из контрольных кадров." ) return ( width, height, fps, frame_count, duration_seconds, first_frame, middle_frame, last_frame, ) def validate_sync_statistics( statistics: list[ProfileStatistics], ) -> None: """ Проверяет N/A async и нулевые mismatch всех sync-профилей. """ for item in statistics: if item.update_mode == UPDATE_MODE_ASYNCHRONOUS: if ( item.synchronized_composite_frames is not None or item.timestamp_mismatch_count is not None or item.frame_id_mismatch_count is not None ): raise RuntimeError( "Async mismatch должен быть N/A." ) else: if ( item.selected_base_frames != item.selected_roi_frames or item.selected_base_frames != item.synchronized_composite_frames or item.timestamp_mismatch_count != 0 or item.frame_id_mismatch_count != 0 ): raise RuntimeError( f"Некорректный sync: {item.profile_name}" ) def main() -> None: """ Выполняет оба прохода, сохраняет CSV/TXT и проверяет preview. """ print("Reading source video metadata...") ( source_width, source_height, source_fps, source_frame_count, source_duration_seconds, source_file_size_bytes, ) = read_video_metadata(SOURCE_VIDEO_PATH) print(f" Source: {SOURCE_VIDEO_PATH}") print(f" File size: {source_file_size_bytes} bytes") print(f" Resolution: {source_width}x{source_height}") print(f" FPS: {source_fps:.6f}") print(f" Frames: {source_frame_count}") print(f" Duration: {source_duration_seconds:.6f} s") profiles = build_profiles() OUTPUT_DIRECTORY.mkdir( parents=True, exist_ok=True, ) print("First pass: measuring JPEG payload...") first_measurements, first_states = process_first_pass( source_path=SOURCE_VIDEO_PATH, profiles=profiles, source_width=source_width, source_height=source_height, source_fps=source_fps, expected_frame_count=source_frame_count, ) statistics = calculate_statistics( profiles, first_measurements, first_states, source_duration_seconds, ) validate_sync_statistics(statistics) print("Second pass: writing preview...") ( preview_path, fallback_used, written_frame_count, second_measurements, second_states, ) = write_preview( source_path=SOURCE_VIDEO_PATH, profiles=profiles, statistics=statistics, source_width=source_width, source_height=source_height, source_fps=source_fps, expected_frame_count=source_frame_count, ) compare_passes( profiles, first_measurements, second_measurements, first_states, second_states, ) print("Saving CSV and report...") save_csv(statistics) write_report( statistics=statistics, source_width=source_width, source_height=source_height, source_fps=source_fps, source_frame_count=source_frame_count, source_duration_seconds=source_duration_seconds, preview_path=preview_path, fallback_used=fallback_used, ) ( preview_width, preview_height, preview_fps, preview_frame_count, preview_duration, first_frame, middle_frame, last_frame, ) = verify_preview( preview_path, source_frame_count, source_duration_seconds, ) if not CSV_PATH.exists() or CSV_PATH.stat().st_size <= 0: raise RuntimeError("CSV не создан или пуст.") if not REPORT_PATH.exists() or REPORT_PATH.stat().st_size <= 0: raise RuntimeError("Отчёт не создан или пуст.") print("") print("Measured profiles:") for item in statistics: print( f" {item.profile_name}: " f"BASE={item.base_bitrate_kbps:.6f}, " f"ROI={item.roi_bitrate_kbps:.6f}, " f"total={item.total_payload_bitrate_kbps:.6f} " "kbit/s, " "sync=" f"{optional_integer_text(item.synchronized_composite_frames)}, " "timestamp mismatch=" f"{optional_integer_text(item.timestamp_mismatch_count)}, " "frame ID mismatch=" f"{optional_integer_text(item.frame_id_mismatch_count)}" ) print("") print("Two-pass JPEG sizes: identical") print(f"Preview: {preview_path}") print(f"Fallback used: {fallback_used}") print(f"Preview size: {preview_path.stat().st_size} bytes") print( f"Preview resolution: {preview_width}x{preview_height}" ) print(f"Preview FPS: {preview_fps:.6f}") print(f"Written frames: {written_frame_count}") print(f"Verified frames: {preview_frame_count}") print(f"Preview duration: {preview_duration:.6f} s") print(f"First frame: {first_frame}") print(f"Middle frame: {middle_frame}") print(f"Last frame: {last_frame}") print("") print("Lab027C completed successfully.") if __name__ == "__main__": main()