1942 lines
56 KiB
Python
1942 lines
56 KiB
Python
"""
|
||
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()
|