Files
SDR-Rover/tests/lab027d_fps_quality_preview.py
2026-07-24 17:54:27 +03:00

1518 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Lab027D. Сравнение синхронного BASE + ROI при 2 и 3 fps.
Лабораторная формирует четыре синхронных профиля с различными
частотой обновления и JPEG Quality. Все JPEG существуют только
в памяти. Первый проход измеряет фактический payload, второй
проход повторяет то же расписание и записывает сравнительное
preview-видео 2x2.
Лучший профиль программой не выбирается: итоговый выбор должен
сделать пользователь после просмотра динамического сравнения.
"""
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/lab027d")
CSV_PATH = OUTPUT_DIRECTORY / "lab027d_profiles.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab027d_report.txt"
PREVIEW_DIRECTORY = Path("data/raw/lab027d_previews")
MP4_PREVIEW_PATH = (
PREVIEW_DIRECTORY / "lab027d_fps_quality_preview.mp4"
)
AVI_PREVIEW_PATH = (
PREVIEW_DIRECTORY / "lab027d_fps_quality_preview.avi"
)
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
PANEL_WIDTH = 640
PANEL_HEIGHT = 360
OUTPUT_WIDTH = PANEL_WIDTH * 2
OUTPUT_HEIGHT = PANEL_HEIGHT * 2
OUTPUT_FPS = 30.0
SERVICE_OVERHEAD_FACTOR = 1.10
FEC_RATE_TWO_THIRDS = 2.0 / 3.0
FEC_RATE_ONE_HALF = 0.5
FRAME_TIME_EPSILON_SECONDS = 1e-9
NEW_LABEL_DURATION_SECONDS = 0.15
CSV_FIELD_NAMES = [
"profile_name",
"base_width",
"base_height",
"fps",
"base_quality",
"roi_width",
"roi_height",
"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",
"channel_rate_fec_2_3_kbps",
"channel_rate_fec_1_2_kbps",
"timestamp_mismatch_count",
"frame_id_mismatch_count",
]
@dataclass(frozen=True)
class PreviewProfile:
"""
Описывает один синхронный профиль BASE + ROI.
"""
profile_name: str
fps: float
base_width: int
base_height: int
base_quality: int
roi_width: int
roi_height: int
roi_quality: int
@property
def period_seconds(self) -> float:
"""
Возвращает период атомарного обновления профиля.
"""
return 1.0 / self.fps
@dataclass
class SyncState:
"""
Хранит последнее атомарно опубликованное состояние профиля.
"""
next_composite_time: float = 0.0
last_composite_update_time: float = float("-inf")
composite_frame_id: int = -1
source_frame_index: int = -1
timestamp: float = 0.0
latest_base: np.ndarray | None = None
latest_roi: np.ndarray | None = None
selected_base_frames: int = 0
selected_roi_frames: int = 0
synchronized_composite_frames: int = 0
timestamp_mismatch_count: int = 0
frame_id_mismatch_count: int = 0
@dataclass(frozen=True)
class ProfileStatistics:
"""
Содержит все измерения одного профиля для CSV и отчёта.
"""
profile_name: str
base_width: int
base_height: int
fps: float
base_quality: int
roi_width: int
roi_height: int
roi_quality: int
source_duration_s: float
selected_base_frames: int
selected_roi_frames: int
synchronized_composite_frames: int
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
channel_rate_fec_2_3_kbps: float
channel_rate_fec_1_2_kbps: float
timestamp_mismatch_count: int
frame_id_mismatch_count: int
Measurements = dict[str, dict[str, list[int]]]
def read_video_metadata(
source_path: Path,
) -> tuple[int, int, float, int, float, int]:
"""
Читает метаданные исходного видео без изменения файла.
"""
if not source_path.exists():
raise FileNotFoundError(
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("Некорректное разрешение исходного видео.")
if fps <= 0.0 or frame_count <= 0:
raise RuntimeError(
"Некорректные FPS или число кадров исходного видео."
)
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]:
"""
Создаёт ровно четыре профиля, заданных для Lab027D.
"""
profiles = [
PreviewProfile(
profile_name="sync_2fps_base_q20_roi_q30",
fps=2.0,
base_width=240,
base_height=135,
base_quality=20,
roi_width=320,
roi_height=180,
roi_quality=30,
),
PreviewProfile(
profile_name="sync_3fps_base_q20_roi_q30",
fps=3.0,
base_width=240,
base_height=135,
base_quality=20,
roi_width=320,
roi_height=180,
roi_quality=30,
),
PreviewProfile(
profile_name="sync_3fps_base_q23_roi_q33",
fps=3.0,
base_width=240,
base_height=135,
base_quality=23,
roi_width=320,
roi_height=180,
roi_quality=33,
),
PreviewProfile(
profile_name="sync_3fps_base_q25_roi_q35",
fps=3.0,
base_width=240,
base_height=135,
base_quality=25,
roi_width=320,
roi_height=180,
roi_quality=35,
),
]
if len(profiles) != 4:
raise RuntimeError("Lab027D должна содержать четыре профиля.")
if len({profile.profile_name for profile in profiles}) != 4:
raise RuntimeError("Имена профилей Lab027D не уникальны.")
return profiles
def normalized_roi_to_pixels(
width: int,
height: int,
) -> tuple[int, int, int, int]:
"""
Переводит нормализованные координаты ROI в пиксели.
"""
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))
if not (
0 <= x_min < x_max <= width
and 0 <= y_min < y_max <= height
):
raise RuntimeError("Расчётная ROI выходит за границы кадра.")
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(
grayscale_image: np.ndarray,
quality: int,
) -> tuple[int, np.ndarray]:
"""
Кодирует grayscale-кадр в JPEG в памяти и сразу декодирует.
"""
encode_parameters = [
int(cv2.IMWRITE_JPEG_QUALITY),
int(quality),
]
encoded, jpeg_buffer = cv2.imencode(
".jpg",
grayscale_image,
encode_parameters,
)
if not encoded:
raise RuntimeError("OpenCV не смог закодировать JPEG.")
decoded = cv2.imdecode(
jpeg_buffer,
cv2.IMREAD_GRAYSCALE,
)
if decoded is None:
raise RuntimeError("OpenCV не смог декодировать JPEG.")
return int(jpeg_buffer.nbytes), decoded
def encode_base(
source_frame: np.ndarray,
profile: PreviewProfile,
) -> tuple[int, np.ndarray]:
"""
Формирует, кодирует и декодирует BASE одного профиля.
"""
grayscale = cv2.cvtColor(
source_frame,
cv2.COLOR_BGR2GRAY,
)
resized = cv2.resize(
grayscale,
(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]:
"""
Вырезает из исходного кадра ROI и обрабатывает JPEG в памяти.
"""
x_min, y_min, x_max, y_max = source_roi
roi = source_frame[y_min:y_max, x_min:x_max]
if roi.size == 0:
raise RuntimeError("Получена пустая ROI.")
grayscale = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
resized = cv2.resize(
grayscale,
(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:
"""
Создаёт пустые списки размеров JPEG для одного прохода.
"""
return {
profile.profile_name: {
"base": [],
"roi": [],
"composite": [],
}
for profile in profiles
}
def create_states(
profiles: list[PreviewProfile],
) -> list[SyncState]:
"""
Создаёт независимое синхронное состояние каждого профиля.
"""
return [SyncState() for _ in profiles]
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:
"""
Атомарно обновляет BASE и ROI из одного исходного кадра.
"""
if not should_update(current_time, state.next_composite_time):
return False
next_composite_frame_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_frame_id = next_composite_frame_id
roi_composite_frame_id = next_composite_frame_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_frame_id != roi_composite_frame_id
):
state.frame_id_mismatch_count += 1
# Публикация выполняется только после готовности обеих частей.
state.latest_base = decoded_base
state.latest_roi = decoded_roi
state.composite_frame_id = next_composite_frame_id
state.source_frame_index = source_frame_index
state.timestamp = current_time
state.last_composite_update_time = current_time
state.next_composite_time += profile.period_seconds
state.selected_base_frames += 1
state.selected_roi_frames += 1
state.synchronized_composite_frames += 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[SyncState]]:
"""
Выполняет первый проход и измеряет JPEG payload без видео.
"""
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):
update_sync_state(
source_frame,
source_roi,
frame_index,
current_time,
profile,
state,
measurements[profile.profile_name],
)
frame_index += 1
if (
frame_index % 100 == 0
or frame_index == expected_frame_count
):
print(
" 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[SyncState],
source_duration_seconds: float,
) -> list[ProfileStatistics]:
"""
Рассчитывает payload и иллюстративные канальные скорости.
"""
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}"
)
if not (
len(base_sizes)
== len(roi_sizes)
== len(composite_sizes)
== state.synchronized_composite_frames
):
raise RuntimeError(
f"Число обновлений не совпало: "
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(
"Суммарный bitrate не равен BASE + ROI."
)
channel_rate_fec_2_3_kbps = (
total_payload_bitrate_kbps
* SERVICE_OVERHEAD_FACTOR
/ FEC_RATE_TWO_THIRDS
)
channel_rate_fec_1_2_kbps = (
total_payload_bitrate_kbps
* SERVICE_OVERHEAD_FACTOR
/ FEC_RATE_ONE_HALF
)
statistics.append(
ProfileStatistics(
profile_name=profile.profile_name,
base_width=profile.base_width,
base_height=profile.base_height,
fps=profile.fps,
base_quality=profile.base_quality,
roi_width=profile.roi_width,
roi_height=profile.roi_height,
roi_quality=profile.roi_quality,
source_duration_s=source_duration_seconds,
selected_base_frames=state.selected_base_frames,
selected_roi_frames=state.selected_roi_frames,
synchronized_composite_frames=(
state.synchronized_composite_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(base_sizes)),
mean_roi_frame_bytes=float(np.mean(roi_sizes)),
mean_composite_frame_bytes=float(
np.mean(composite_sizes)
),
p95_composite_frame_bytes=float(
np.percentile(composite_sizes, 95)
),
max_composite_frame_bytes=int(max(composite_sizes)),
base_bitrate_kbps=base_bitrate_kbps,
roi_bitrate_kbps=roi_bitrate_kbps,
total_payload_bitrate_kbps=(
total_payload_bitrate_kbps
),
channel_rate_fec_2_3_kbps=(
channel_rate_fec_2_3_kbps
),
channel_rate_fec_1_2_kbps=(
channel_rate_fec_1_2_kbps
),
timestamp_mismatch_count=(
state.timestamp_mismatch_count
),
frame_id_mismatch_count=(
state.frame_id_mismatch_count
),
)
)
return statistics
def reconstruct_panel(
profile: PreviewProfile,
state: SyncState,
) -> np.ndarray:
"""
Реконструирует панель 640x360 из последнего BASE и ROI.
"""
if state.latest_base is None or state.latest_roi is None:
return np.zeros(
(PANEL_HEIGHT, PANEL_WIDTH, 3),
dtype=np.uint8,
)
base_large = cv2.resize(
state.latest_base,
(PANEL_WIDTH, PANEL_HEIGHT),
interpolation=cv2.INTER_LINEAR,
)
panel = cv2.cvtColor(base_large, cv2.COLOR_GRAY2BGR)
panel_roi = normalized_roi_to_pixels(
PANEL_WIDTH,
PANEL_HEIGHT,
)
x_min, y_min, x_max, y_max = panel_roi
roi_large = cv2.resize(
state.latest_roi,
(x_max - x_min, y_max - y_min),
interpolation=cv2.INTER_LINEAR,
)
roi_bgr = cv2.cvtColor(roi_large, cv2.COLOR_GRAY2BGR)
panel[y_min:y_max, x_min:x_max] = roi_bgr
cv2.rectangle(
panel,
(x_min, y_min),
(x_max - 1, y_max - 1),
(0, 255, 255),
2,
)
return panel
def draw_text_line(
panel: np.ndarray,
text: str,
line_index: int,
color: tuple[int, int, int] = (255, 255, 255),
) -> None:
"""
Рисует одну строку читаемой подписи на панели.
"""
y_position = 22 + line_index * 21
cv2.putText(
panel,
text,
(9, y_position),
cv2.FONT_HERSHEY_SIMPLEX,
0.48,
(0, 0, 0),
3,
cv2.LINE_AA,
)
cv2.putText(
panel,
text,
(9, y_position),
cv2.FONT_HERSHEY_SIMPLEX,
0.48,
color,
1,
cv2.LINE_AA,
)
def draw_sync_information(
panel: np.ndarray,
profile: PreviewProfile,
state: SyncState,
statistics: ProfileStatistics,
current_time: float,
) -> None:
"""
Добавляет к панели параметры и динамическое состояние.
"""
age_seconds = max(
0.0,
current_time - state.last_composite_update_time,
)
lines = [
f"{profile.profile_name} | SYNCHRONOUS",
f"FPS {profile.fps:.0f} | "
f"BASE {profile.base_width}x{profile.base_height} "
f"Q{profile.base_quality}",
f"ROI {profile.roi_width}x{profile.roi_height} "
f"Q{profile.roi_quality}",
"payload "
f"{statistics.total_payload_bitrate_kbps:.3f} kbit/s",
"channel FEC 2/3 "
f"{statistics.channel_rate_fec_2_3_kbps:.3f} kbit/s",
f"time {current_time:.3f} s | "
f"composite ID {state.composite_frame_id}",
f"source frame {state.source_frame_index} | "
f"age {age_seconds:.3f} s",
]
for line_index, text in enumerate(lines):
draw_text_line(panel, text, line_index)
if age_seconds <= NEW_LABEL_DURATION_SECONDS:
draw_text_line(
panel,
"NEW COMPOSITE",
len(lines),
color=(0, 255, 0),
)
def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
"""
Объединяет четыре панели в сетку 2x2 размером 1280x720.
"""
if len(panels) != 4:
raise RuntimeError("Для preview требуется четыре панели.")
top_row = np.hstack((panels[0], panels[1]))
bottom_row = np.hstack((panels[2], panels[3]))
grid = np.vstack((top_row, bottom_row))
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 или разрешённый AVI/MJPG fallback.
"""
PREVIEW_DIRECTORY.mkdir(parents=True, exist_ok=True)
mp4_writer = cv2.VideoWriter(
str(MP4_PREVIEW_PATH),
cv2.VideoWriter_fourcc(*"mp4v"),
OUTPUT_FPS,
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
)
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),
)
if not avi_writer.isOpened():
avi_writer.release()
raise RuntimeError(
"Не удалось открыть MP4/mp4v и AVI/MJPG 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[SyncState],
]:
"""
Выполняет второй проход и записывает сравнительное 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}"
)
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, profile_statistics in zip(
profiles,
states,
statistics,
):
update_sync_state(
source_frame,
source_roi,
frame_index,
current_time,
profile,
state,
measurements[profile.profile_name],
)
panel = reconstruct_panel(profile, state)
draw_sync_information(
panel,
profile,
state,
profile_statistics,
current_time,
)
panels.append(panel)
writer.write(compose_grid(panels))
frame_index += 1
if (
frame_index % 100 == 0
or frame_index == expected_frame_count
):
print(
" Second pass frames: "
f"{frame_index}/{expected_frame_count}"
)
finally:
capture.release()
writer.release()
if frame_index != expected_frame_count:
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[SyncState],
second_states: list[SyncState],
) -> None:
"""
Проверяет полное совпадение измерений двух проходов.
"""
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 (
first_state.selected_base_frames
!= second_state.selected_base_frames
or first_state.selected_roi_frames
!= second_state.selected_roi_frames
or first_state.synchronized_composite_frames
!= second_state.synchronized_composite_frames
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(
"Состояния двух проходов не совпали: "
f"{profile_name}."
)
def validate_statistics(
statistics: list[ProfileStatistics],
) -> None:
"""
Проверяет синхронность и арифметику всех профилей.
"""
if len(statistics) != 4:
raise RuntimeError("Ожидалось четыре строки статистики.")
for item in statistics:
if not (
item.selected_base_frames
== item.selected_roi_frames
== item.synchronized_composite_frames
):
raise RuntimeError(
f"Обновления не синхронны: {item.profile_name}"
)
if (
item.timestamp_mismatch_count != 0
or item.frame_id_mismatch_count != 0
):
raise RuntimeError(
f"Обнаружен mismatch: {item.profile_name}"
)
if (
item.total_payload_bytes
!= item.total_base_bytes + item.total_roi_bytes
):
raise RuntimeError(
f"Ошибка суммы payload: {item.profile_name}"
)
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,
"base_width": item.base_width,
"base_height": item.base_height,
"fps": f"{item.fps:.6f}",
"base_quality": item.base_quality,
"roi_width": item.roi_width,
"roi_height": item.roi_height,
"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": (
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}"
),
"channel_rate_fec_2_3_kbps": (
f"{item.channel_rate_fec_2_3_kbps:.6f}"
),
"channel_rate_fec_1_2_kbps": (
f"{item.channel_rate_fec_1_2_kbps:.6f}"
),
"timestamp_mismatch_count": (
item.timestamp_mismatch_count
),
"frame_id_mismatch_count": (
item.frame_id_mismatch_count
),
}
)
def format_statistics_line(item: ProfileStatistics) -> str:
"""
Формирует одну подробную строку текстового отчёта.
"""
return (
f"{item.profile_name}: "
f"{item.fps:.3f} fps; "
f"BASE={item.base_width}x{item.base_height}, "
f"Q{item.base_quality}, "
f"frames={item.selected_base_frames}, "
f"bytes={item.total_base_bytes}, "
f"bitrate={item.base_bitrate_kbps:.6f} kbit/s; "
f"ROI={item.roi_width}x{item.roi_height}, "
f"Q{item.roi_quality}, "
f"frames={item.selected_roi_frames}, "
f"bytes={item.total_roi_bytes}, "
f"bitrate={item.roi_bitrate_kbps:.6f} kbit/s; "
f"total_bytes={item.total_payload_bytes}, "
f"payload={item.total_payload_bitrate_kbps:.6f} kbit/s; "
f"channel FEC 2/3="
f"{item.channel_rate_fec_2_3_kbps:.6f} kbit/s; "
f"channel FEC 1/2="
f"{item.channel_rate_fec_1_2_kbps:.6f} kbit/s; "
f"sync_frames={item.synchronized_composite_frames}; "
f"timestamp_mismatch="
f"{item.timestamp_mismatch_count}; "
f"frame_id_mismatch={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 = [
"Lab027D. Синхронный BASE + ROI при 2 и 3 fps",
"",
"Цель: сравнить текущий синхронный профиль 2 fps "
"с тремя профилями 3 fps при разных JPEG Quality.",
"",
"Результат ручной оценки Lab027C:",
"- лучший из показанных вариантов — нижний левый;",
"- синхронизация BASE и ROI устранила рассогласование;",
"- 2 fps недостаточно;",
"- BASE 160x90 слишком грубый;",
"- BASE 240x135 Q20 немного недостаточен по качеству.",
"",
"Причина повышения FPS: ручная оценка показала, что "
"синхронное обновление устраняет рассогласование, но "
"частота 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]}.",
"",
"Все профили синхронные: BASE и ROI формируются из "
"одного source frame, получают единый timestamp и "
"composite frame ID и публикуются атомарно.",
"",
"Фактические результаты:",
]
lines.extend(
format_statistics_line(item)
for item in statistics
)
lines.extend(
[
"",
"Оценки channel rate иллюстративны: к payload "
"добавлено 10% служебных данных, затем применена "
"оценка FEC 2/3 или FEC 1/2.",
"Эти значения не являются окончательной архитектурой "
"радиоканала.",
"",
f"Preview: {preview_path}",
"Формат: "
+ ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."),
"",
"Предупреждение: оценки пока не включают окончательную "
"модуляцию, полосу сигнала, интерливинг, повторы и "
"команды управления.",
"Программа не выбирает лучший профиль автоматически.",
"Следующий шаг: ручной выбор пользователя после "
"просмотра 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(
"Не удалось прочитать контрольный кадр preview."
)
return (
width,
height,
fps,
frame_count,
duration_seconds,
first_frame,
middle_frame,
last_frame,
)
def main() -> None:
"""
Выполняет два прохода Lab027D и проверяет результаты.
"""
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()
print("First pass: measuring synchronized JPEG payload...")
first_measurements, first_states = process_first_pass(
SOURCE_VIDEO_PATH,
profiles,
source_width,
source_height,
source_fps,
source_frame_count,
)
statistics = calculate_statistics(
profiles,
first_measurements,
first_states,
source_duration_seconds,
)
validate_statistics(statistics)
print("Second pass: writing preview...")
(
preview_path,
fallback_used,
written_frame_count,
second_measurements,
second_states,
) = write_preview(
SOURCE_VIDEO_PATH,
profiles,
statistics,
source_width,
source_height,
source_fps,
source_frame_count,
)
compare_passes(
profiles,
first_measurements,
second_measurements,
first_states,
second_states,
)
second_statistics = calculate_statistics(
profiles,
second_measurements,
second_states,
source_duration_seconds,
)
validate_statistics(second_statistics)
if statistics != second_statistics:
raise RuntimeError(
"Итоговая статистика двух проходов не совпала."
)
(
preview_width,
preview_height,
preview_fps,
verified_frame_count,
preview_duration_seconds,
first_frame,
middle_frame,
last_frame,
) = verify_preview(
preview_path,
source_frame_count,
source_duration_seconds,
)
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
save_csv(statistics)
write_report(
statistics,
source_width,
source_height,
source_fps,
source_frame_count,
source_duration_seconds,
preview_path,
fallback_used,
)
print("")
print("Measured profiles:")
for item in statistics:
print(
f" {item.profile_name}: "
f"updates={item.synchronized_composite_frames}, "
f"BASE={item.base_bitrate_kbps:.6f}, "
f"ROI={item.roi_bitrate_kbps:.6f}, "
f"payload={item.total_payload_bitrate_kbps:.6f}, "
f"FEC 2/3={item.channel_rate_fec_2_3_kbps:.6f}, "
f"FEC 1/2={item.channel_rate_fec_1_2_kbps:.6f} "
"kbit/s, "
f"timestamp mismatch={item.timestamp_mismatch_count}, "
f"frame ID mismatch={item.frame_id_mismatch_count}"
)
print("")
print("Two-pass JPEG sizes and statistics: 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: "
f"{preview_width}x{preview_height}"
)
print(f"Preview FPS: {preview_fps:.6f}")
print(f"Written frames: {written_frame_count}")
print(f"Verified frames: {verified_frame_count}")
print(
f"Preview duration: "
f"{preview_duration_seconds:.6f} s"
)
print(f"First frame: {first_frame}")
print(f"Middle frame: {middle_frame}")
print(f"Last frame: {last_frame}")
print("")
print("Lab027D completed successfully.")
if __name__ == "__main__":
main()