Files
SDR-Rover/experiments/lab027c_synchronous_roi_preview.py
LittleSam129 c486039053 Split experiments from tests
The tests/ directory held 50 laboratory programs and no tests. They model
channels, run hundreds of repetitions and write CSV, PNG and reports;
calling that a test suite blocked introducing a real one, because any
pytest run would have collected the labs and re-executed every
experiment.

- move all 50 lab programs to experiments/ with git mv, preserving history
- rewrite the 38 cross-imports between labs from tests.labNNN to
  experiments.labNNN
- leave tests/ empty for actual fast checks of protocol/
- point quick_gate and the hook at the new layout and add experiments/ to
  the syntax sweep
- update the paths quoted in the Lab042 specification and the verifier
  agent definition

This also defuses the import-time work finding without touching 41 files:
the labs still create directories and write files on import, but nothing
imports them now except the gate, which does so deliberately.

Gate passes: syntax clean, protocol imports, 15 lab modules import, 2
functional suites run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:34:58 +03:00

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