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>
1576 lines
46 KiB
Python
1576 lines
46 KiB
Python
"""
|
||
Lab027E. Операторское preview синхронного BASE + ROI.
|
||
|
||
Лабораторная имитирует изображение на операторской стороне для
|
||
предварительно выбранного профиля BASE 240x135 Q23 и ROI 320x180
|
||
Q33 при трёх атомарных обновлениях в секунду. Содержимое исходной
|
||
сцены воспроизводится со скоростью 0.5x.
|
||
|
||
JPEG-кодирование выполняется только при обновлении составного
|
||
изображения. Отдельные JPEG-файлы не создаются. Первый проход
|
||
измеряет payload, второй повторяет расписание и записывает preview.
|
||
"""
|
||
|
||
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/lab027e")
|
||
CSV_PATH = OUTPUT_DIRECTORY / "lab027e_profile.csv"
|
||
REPORT_PATH = OUTPUT_DIRECTORY / "lab027e_report.txt"
|
||
|
||
PREVIEW_DIRECTORY = Path("data/raw/lab027e_previews")
|
||
MP4_PREVIEW_PATH = (
|
||
PREVIEW_DIRECTORY / "lab027e_operator_view_0_5x.mp4"
|
||
)
|
||
AVI_PREVIEW_PATH = (
|
||
PREVIEW_DIRECTORY / "lab027e_operator_view_0_5x.avi"
|
||
)
|
||
|
||
PROFILE_NAME = "sync_3fps_base240_q23_roi320_q33_speed_0_5x"
|
||
|
||
PLAYBACK_SPEED_FACTOR = 0.5
|
||
COMPOSITE_FPS = 3.0
|
||
|
||
BASE_WIDTH = 240
|
||
BASE_HEIGHT = 135
|
||
BASE_QUALITY = 23
|
||
|
||
ROI_WIDTH = 320
|
||
ROI_HEIGHT = 180
|
||
ROI_QUALITY = 33
|
||
|
||
ROI_X_MIN = 0.20
|
||
ROI_X_MAX = 0.80
|
||
ROI_Y_MIN = 0.42
|
||
ROI_Y_MAX = 1.00
|
||
|
||
COMPOSITE_WIDTH = 640
|
||
COMPOSITE_HEIGHT = 360
|
||
OUTPUT_WIDTH = 1280
|
||
OUTPUT_HEIGHT = 720
|
||
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
|
||
|
||
CSV_FIELD_NAMES = [
|
||
"profile_name",
|
||
"playback_speed_factor",
|
||
"source_width",
|
||
"source_height",
|
||
"source_fps",
|
||
"source_frames",
|
||
"source_duration_s",
|
||
"output_width",
|
||
"output_height",
|
||
"output_fps",
|
||
"output_frames",
|
||
"output_duration_s",
|
||
"composite_fps",
|
||
"base_width",
|
||
"base_height",
|
||
"base_quality",
|
||
"roi_width",
|
||
"roi_height",
|
||
"roi_quality",
|
||
"selected_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 OperatorProfile:
|
||
"""
|
||
Описывает выбранный операторский видеорежим.
|
||
"""
|
||
|
||
profile_name: str
|
||
playback_speed_factor: float
|
||
composite_fps: float
|
||
base_width: int
|
||
base_height: int
|
||
base_quality: int
|
||
roi_width: int
|
||
roi_height: int
|
||
roi_quality: int
|
||
|
||
@property
|
||
def composite_period_seconds(self) -> float:
|
||
"""
|
||
Возвращает период обновления составного изображения.
|
||
"""
|
||
|
||
return 1.0 / self.composite_fps
|
||
|
||
|
||
@dataclass
|
||
class CompositeState:
|
||
"""
|
||
Хранит последнее атомарно опубликованное изображение.
|
||
"""
|
||
|
||
next_composite_time: float = 0.0
|
||
last_composite_update_time: float = float("-inf")
|
||
composite_frame_id: int = -1
|
||
source_frame_index: int = -1
|
||
source_timestamp: float = 0.0
|
||
latest_composite: np.ndarray | None = None
|
||
selected_composite_frames: int = 0
|
||
timestamp_mismatch_count: int = 0
|
||
frame_id_mismatch_count: int = 0
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProfileStatistics:
|
||
"""
|
||
Содержит итоговую строку измерений Lab027E.
|
||
"""
|
||
|
||
profile_name: str
|
||
playback_speed_factor: float
|
||
source_width: int
|
||
source_height: int
|
||
source_fps: float
|
||
source_frames: int
|
||
source_duration_s: float
|
||
output_width: int
|
||
output_height: int
|
||
output_fps: float
|
||
output_frames: int
|
||
output_duration_s: float
|
||
composite_fps: float
|
||
base_width: int
|
||
base_height: int
|
||
base_quality: int
|
||
roi_width: int
|
||
roi_height: int
|
||
roi_quality: int
|
||
selected_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, list[int]]
|
||
|
||
|
||
def build_profile() -> OperatorProfile:
|
||
"""
|
||
Создаёт единственный профиль Lab027E.
|
||
"""
|
||
|
||
return OperatorProfile(
|
||
profile_name=PROFILE_NAME,
|
||
playback_speed_factor=PLAYBACK_SPEED_FACTOR,
|
||
composite_fps=COMPOSITE_FPS,
|
||
base_width=BASE_WIDTH,
|
||
base_height=BASE_HEIGHT,
|
||
base_quality=BASE_QUALITY,
|
||
roi_width=ROI_WIDTH,
|
||
roi_height=ROI_HEIGHT,
|
||
roi_quality=ROI_QUALITY,
|
||
)
|
||
|
||
|
||
def read_video_metadata(
|
||
source_path: Path,
|
||
) -> tuple[int, int, float, int, float, int]:
|
||
"""
|
||
Читает метаданные исходного MP4 без изменения файла.
|
||
"""
|
||
|
||
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 calculate_output_frame_count(
|
||
source_frame_count: int,
|
||
source_fps: float,
|
||
output_fps: float,
|
||
playback_speed_factor: float,
|
||
) -> int:
|
||
"""
|
||
Вычисляет число выходных кадров без выхода за исходный диапазон.
|
||
|
||
Последний допустимый output_frame_index должен давать
|
||
source_frame_index не больше source_frame_count - 1.
|
||
"""
|
||
|
||
if (
|
||
source_frame_count <= 0
|
||
or source_fps <= 0.0
|
||
or output_fps <= 0.0
|
||
or playback_speed_factor <= 0.0
|
||
):
|
||
raise ValueError(
|
||
"Параметры временной модели должны быть положительными."
|
||
)
|
||
|
||
source_frames_per_output_frame = (
|
||
playback_speed_factor * source_fps / output_fps
|
||
)
|
||
output_frame_count = int(
|
||
np.ceil(
|
||
source_frame_count
|
||
/ source_frames_per_output_frame
|
||
)
|
||
)
|
||
|
||
if output_frame_count <= 0:
|
||
raise RuntimeError(
|
||
"Рассчитано некорректное число выходных кадров."
|
||
)
|
||
|
||
last_source_index = source_frame_index_for_output(
|
||
output_frame_count - 1,
|
||
output_fps,
|
||
playback_speed_factor,
|
||
source_fps,
|
||
source_frame_count,
|
||
)
|
||
next_unclamped_source_index = int(
|
||
np.floor(
|
||
output_frame_count
|
||
/ output_fps
|
||
* playback_speed_factor
|
||
* source_fps
|
||
+ FRAME_TIME_EPSILON_SECONDS
|
||
)
|
||
)
|
||
|
||
if last_source_index >= source_frame_count:
|
||
raise RuntimeError(
|
||
"Последний выходной кадр вышел за исходный диапазон."
|
||
)
|
||
|
||
if next_unclamped_source_index < source_frame_count:
|
||
raise RuntimeError(
|
||
"Рассчитано недостаточное число выходных кадров."
|
||
)
|
||
|
||
return output_frame_count
|
||
|
||
|
||
def source_frame_index_for_output(
|
||
output_frame_index: int,
|
||
output_fps: float,
|
||
playback_speed_factor: float,
|
||
source_fps: float,
|
||
source_frame_count: int,
|
||
) -> int:
|
||
"""
|
||
Детерминированно сопоставляет выходной кадр исходному.
|
||
"""
|
||
|
||
output_time = output_frame_index / output_fps
|
||
source_time = output_time * playback_speed_factor
|
||
source_frame_index = int(
|
||
np.floor(
|
||
source_time * source_fps
|
||
+ FRAME_TIME_EPSILON_SECONDS
|
||
)
|
||
)
|
||
|
||
return min(
|
||
max(source_frame_index, 0),
|
||
source_frame_count - 1,
|
||
)
|
||
|
||
|
||
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(
|
||
output_time: float,
|
||
next_composite_time: float,
|
||
) -> bool:
|
||
"""
|
||
Проверяет наступление времени composite-обновления.
|
||
"""
|
||
|
||
return (
|
||
output_time + FRAME_TIME_EPSILON_SECONDS
|
||
>= next_composite_time
|
||
)
|
||
|
||
|
||
def read_source_frame_sequentially(
|
||
capture: cv2.VideoCapture,
|
||
current_source_frame_index: int,
|
||
current_source_frame: np.ndarray | None,
|
||
target_source_frame_index: int,
|
||
) -> tuple[int, np.ndarray]:
|
||
"""
|
||
Последовательно читает исходник до заданного индекса.
|
||
|
||
Случайный seek не используется. Если запрошен тот же индекс,
|
||
возвращается уже прочитанный кадр.
|
||
"""
|
||
|
||
if target_source_frame_index < current_source_frame_index:
|
||
raise RuntimeError(
|
||
"Последовательное чтение не допускает возврат назад."
|
||
)
|
||
|
||
while current_source_frame_index < target_source_frame_index:
|
||
frame_read, source_frame = capture.read()
|
||
|
||
if not frame_read or source_frame is None:
|
||
raise RuntimeError(
|
||
"Исходное видео закончилось до целевого кадра "
|
||
f"{target_source_frame_index}."
|
||
)
|
||
|
||
current_source_frame_index += 1
|
||
current_source_frame = source_frame
|
||
|
||
if current_source_frame is None:
|
||
raise RuntimeError("Не удалось получить исходный кадр.")
|
||
|
||
return current_source_frame_index, current_source_frame
|
||
|
||
|
||
def encode_decode_jpeg(
|
||
grayscale_image: np.ndarray,
|
||
quality: int,
|
||
) -> tuple[int, np.ndarray]:
|
||
"""
|
||
Кодирует grayscale-изображение в JPEG в памяти и декодирует.
|
||
"""
|
||
|
||
encoded, jpeg_buffer = cv2.imencode(
|
||
".jpg",
|
||
grayscale_image,
|
||
[
|
||
int(cv2.IMWRITE_JPEG_QUALITY),
|
||
int(quality),
|
||
],
|
||
)
|
||
|
||
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: OperatorProfile,
|
||
) -> tuple[int, np.ndarray]:
|
||
"""
|
||
Формирует BASE заданного размера и JPEG Quality.
|
||
"""
|
||
|
||
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: OperatorProfile,
|
||
) -> tuple[int, np.ndarray]:
|
||
"""
|
||
Формирует ROI заданного размера и JPEG Quality.
|
||
"""
|
||
|
||
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 build_composite(
|
||
decoded_base: np.ndarray,
|
||
decoded_roi: np.ndarray,
|
||
) -> np.ndarray:
|
||
"""
|
||
Собирает фактическое составное изображение 640x360.
|
||
|
||
ROI заменяет соответствующую область напрямую, без рамки,
|
||
смешивания и сглаживания границы.
|
||
"""
|
||
|
||
base_large = cv2.resize(
|
||
decoded_base,
|
||
(COMPOSITE_WIDTH, COMPOSITE_HEIGHT),
|
||
interpolation=cv2.INTER_LINEAR,
|
||
)
|
||
composite = cv2.cvtColor(
|
||
base_large,
|
||
cv2.COLOR_GRAY2BGR,
|
||
)
|
||
|
||
x_min, y_min, x_max, y_max = normalized_roi_to_pixels(
|
||
COMPOSITE_WIDTH,
|
||
COMPOSITE_HEIGHT,
|
||
)
|
||
roi_large = cv2.resize(
|
||
decoded_roi,
|
||
(x_max - x_min, y_max - y_min),
|
||
interpolation=cv2.INTER_LINEAR,
|
||
)
|
||
composite[y_min:y_max, x_min:x_max] = cv2.cvtColor(
|
||
roi_large,
|
||
cv2.COLOR_GRAY2BGR,
|
||
)
|
||
|
||
return composite
|
||
|
||
|
||
def create_measurements() -> Measurements:
|
||
"""
|
||
Создаёт пустые измерения одного прохода.
|
||
"""
|
||
|
||
return {
|
||
"base": [],
|
||
"roi": [],
|
||
"composite": [],
|
||
"source_indices": [],
|
||
}
|
||
|
||
|
||
def update_composite_state(
|
||
source_frame: np.ndarray,
|
||
source_roi: tuple[int, int, int, int],
|
||
source_frame_index: int,
|
||
source_fps: float,
|
||
output_time: float,
|
||
profile: OperatorProfile,
|
||
state: CompositeState,
|
||
measurements: Measurements,
|
||
) -> None:
|
||
"""
|
||
Кодирует обе части и атомарно публикует составной кадр.
|
||
"""
|
||
|
||
next_composite_frame_id = state.composite_frame_id + 1
|
||
source_timestamp = source_frame_index / source_fps
|
||
|
||
base_source_frame_index = source_frame_index
|
||
roi_source_frame_index = source_frame_index
|
||
base_source_timestamp = source_timestamp
|
||
roi_source_timestamp = source_timestamp
|
||
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_source_timestamp != roi_source_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
|
||
|
||
composite = build_composite(decoded_base, decoded_roi)
|
||
|
||
# Все отображаемые поля заменяются только после готовности
|
||
# BASE, ROI и реконструированного изображения.
|
||
state.latest_composite = composite
|
||
state.composite_frame_id = next_composite_frame_id
|
||
state.source_frame_index = source_frame_index
|
||
state.source_timestamp = source_timestamp
|
||
state.last_composite_update_time = output_time
|
||
state.next_composite_time += (
|
||
profile.composite_period_seconds
|
||
)
|
||
state.selected_composite_frames += 1
|
||
|
||
measurements["base"].append(base_size)
|
||
measurements["roi"].append(roi_size)
|
||
measurements["composite"].append(base_size + roi_size)
|
||
measurements["source_indices"].append(source_frame_index)
|
||
|
||
|
||
def process_first_pass(
|
||
source_path: Path,
|
||
profile: OperatorProfile,
|
||
source_width: int,
|
||
source_height: int,
|
||
source_fps: float,
|
||
source_frame_count: int,
|
||
output_frame_count: int,
|
||
) -> tuple[Measurements, CompositeState]:
|
||
"""
|
||
Измеряет JPEG payload без создания видео.
|
||
"""
|
||
|
||
measurements = create_measurements()
|
||
state = CompositeState()
|
||
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}"
|
||
)
|
||
|
||
current_source_frame_index = -1
|
||
current_source_frame: np.ndarray | None = None
|
||
|
||
try:
|
||
for output_frame_index in range(output_frame_count):
|
||
output_time = output_frame_index / OUTPUT_FPS
|
||
|
||
if not should_update(
|
||
output_time,
|
||
state.next_composite_time,
|
||
):
|
||
continue
|
||
|
||
target_source_frame_index = (
|
||
source_frame_index_for_output(
|
||
output_frame_index,
|
||
OUTPUT_FPS,
|
||
profile.playback_speed_factor,
|
||
source_fps,
|
||
source_frame_count,
|
||
)
|
||
)
|
||
(
|
||
current_source_frame_index,
|
||
current_source_frame,
|
||
) = read_source_frame_sequentially(
|
||
capture,
|
||
current_source_frame_index,
|
||
current_source_frame,
|
||
target_source_frame_index,
|
||
)
|
||
update_composite_state(
|
||
current_source_frame,
|
||
source_roi,
|
||
current_source_frame_index,
|
||
source_fps,
|
||
output_time,
|
||
profile,
|
||
state,
|
||
measurements,
|
||
)
|
||
|
||
if state.selected_composite_frames % 25 == 0:
|
||
print(
|
||
" First pass composite frames: "
|
||
f"{state.selected_composite_frames}"
|
||
)
|
||
finally:
|
||
capture.release()
|
||
|
||
if state.latest_composite is None:
|
||
raise RuntimeError(
|
||
"Первый проход не сформировал составных кадров."
|
||
)
|
||
|
||
return measurements, state
|
||
|
||
|
||
def calculate_statistics(
|
||
profile: OperatorProfile,
|
||
measurements: Measurements,
|
||
state: CompositeState,
|
||
source_width: int,
|
||
source_height: int,
|
||
source_fps: float,
|
||
source_frame_count: int,
|
||
source_duration_seconds: float,
|
||
output_frame_count: int,
|
||
) -> ProfileStatistics:
|
||
"""
|
||
Рассчитывает bitrate по длительности замедленного preview.
|
||
"""
|
||
|
||
base_sizes = measurements["base"]
|
||
roi_sizes = measurements["roi"]
|
||
composite_sizes = measurements["composite"]
|
||
|
||
if not base_sizes or not roi_sizes or not composite_sizes:
|
||
raise RuntimeError("Получены пустые измерения JPEG.")
|
||
|
||
if not (
|
||
len(base_sizes)
|
||
== len(roi_sizes)
|
||
== len(composite_sizes)
|
||
== len(measurements["source_indices"])
|
||
== state.selected_composite_frames
|
||
):
|
||
raise RuntimeError(
|
||
"Число измерений и составных кадров не совпало."
|
||
)
|
||
|
||
output_duration_seconds = output_frame_count / OUTPUT_FPS
|
||
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
|
||
/ output_duration_seconds
|
||
/ 1000.0
|
||
)
|
||
roi_bitrate_kbps = (
|
||
total_roi_bytes
|
||
* 8.0
|
||
/ output_duration_seconds
|
||
/ 1000.0
|
||
)
|
||
total_payload_bitrate_kbps = (
|
||
total_payload_bytes
|
||
* 8.0
|
||
/ output_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
|
||
)
|
||
|
||
return ProfileStatistics(
|
||
profile_name=profile.profile_name,
|
||
playback_speed_factor=profile.playback_speed_factor,
|
||
source_width=source_width,
|
||
source_height=source_height,
|
||
source_fps=source_fps,
|
||
source_frames=source_frame_count,
|
||
source_duration_s=source_duration_seconds,
|
||
output_width=OUTPUT_WIDTH,
|
||
output_height=OUTPUT_HEIGHT,
|
||
output_fps=OUTPUT_FPS,
|
||
output_frames=output_frame_count,
|
||
output_duration_s=output_duration_seconds,
|
||
composite_fps=profile.composite_fps,
|
||
base_width=profile.base_width,
|
||
base_height=profile.base_height,
|
||
base_quality=profile.base_quality,
|
||
roi_width=profile.roi_width,
|
||
roi_height=profile.roi_height,
|
||
roi_quality=profile.roi_quality,
|
||
selected_composite_frames=(
|
||
state.selected_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
|
||
),
|
||
)
|
||
|
||
|
||
def draw_minimal_status(
|
||
preview_frame: np.ndarray,
|
||
profile: OperatorProfile,
|
||
statistics: ProfileStatistics,
|
||
composite_age_seconds: float,
|
||
) -> None:
|
||
"""
|
||
Рисует небольшой непрозрачный статусный блок слева сверху.
|
||
"""
|
||
|
||
block_width = 410
|
||
block_height = 142
|
||
cv2.rectangle(
|
||
preview_frame,
|
||
(0, 0),
|
||
(block_width, block_height),
|
||
(0, 0, 0),
|
||
cv2.FILLED,
|
||
)
|
||
|
||
lines = [
|
||
f"{profile.composite_fps:.0f} fps",
|
||
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}",
|
||
f"playback {profile.playback_speed_factor:.1f}x",
|
||
"payload "
|
||
f"{statistics.total_payload_bitrate_kbps:.3f} kbit/s",
|
||
f"age {composite_age_seconds:.3f} s",
|
||
]
|
||
|
||
for line_index, text in enumerate(lines):
|
||
cv2.putText(
|
||
preview_frame,
|
||
text,
|
||
(10, 22 + line_index * 22),
|
||
cv2.FONT_HERSHEY_SIMPLEX,
|
||
0.55,
|
||
(255, 255, 255),
|
||
1,
|
||
cv2.LINE_AA,
|
||
)
|
||
|
||
|
||
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_operator_preview(
|
||
source_path: Path,
|
||
profile: OperatorProfile,
|
||
statistics: ProfileStatistics,
|
||
source_width: int,
|
||
source_height: int,
|
||
source_fps: float,
|
||
source_frame_count: int,
|
||
output_frame_count: int,
|
||
) -> tuple[
|
||
Path,
|
||
bool,
|
||
Measurements,
|
||
CompositeState,
|
||
]:
|
||
"""
|
||
Повторяет расписание и записывает одно полноэкранное preview.
|
||
"""
|
||
|
||
measurements = create_measurements()
|
||
state = CompositeState()
|
||
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()
|
||
current_source_frame_index = -1
|
||
current_source_frame: np.ndarray | None = None
|
||
|
||
try:
|
||
for output_frame_index in range(output_frame_count):
|
||
output_time = output_frame_index / OUTPUT_FPS
|
||
|
||
if should_update(
|
||
output_time,
|
||
state.next_composite_time,
|
||
):
|
||
target_source_frame_index = (
|
||
source_frame_index_for_output(
|
||
output_frame_index,
|
||
OUTPUT_FPS,
|
||
profile.playback_speed_factor,
|
||
source_fps,
|
||
source_frame_count,
|
||
)
|
||
)
|
||
(
|
||
current_source_frame_index,
|
||
current_source_frame,
|
||
) = read_source_frame_sequentially(
|
||
capture,
|
||
current_source_frame_index,
|
||
current_source_frame,
|
||
target_source_frame_index,
|
||
)
|
||
update_composite_state(
|
||
current_source_frame,
|
||
source_roi,
|
||
current_source_frame_index,
|
||
source_fps,
|
||
output_time,
|
||
profile,
|
||
state,
|
||
measurements,
|
||
)
|
||
|
||
if state.latest_composite is None:
|
||
raise RuntimeError(
|
||
"Отсутствует составное изображение для preview."
|
||
)
|
||
|
||
preview_frame = cv2.resize(
|
||
state.latest_composite,
|
||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||
interpolation=cv2.INTER_LINEAR,
|
||
)
|
||
composite_age_seconds = max(
|
||
0.0,
|
||
output_time - state.last_composite_update_time,
|
||
)
|
||
draw_minimal_status(
|
||
preview_frame,
|
||
profile,
|
||
statistics,
|
||
composite_age_seconds,
|
||
)
|
||
writer.write(preview_frame)
|
||
|
||
written_frames = output_frame_index + 1
|
||
|
||
if (
|
||
written_frames % 200 == 0
|
||
or written_frames == output_frame_count
|
||
):
|
||
print(
|
||
" Preview frames: "
|
||
f"{written_frames}/{output_frame_count}"
|
||
)
|
||
finally:
|
||
capture.release()
|
||
writer.release()
|
||
|
||
return (
|
||
preview_path,
|
||
fallback_used,
|
||
measurements,
|
||
state,
|
||
)
|
||
|
||
|
||
def compare_passes(
|
||
first_measurements: Measurements,
|
||
second_measurements: Measurements,
|
||
first_state: CompositeState,
|
||
second_state: CompositeState,
|
||
) -> None:
|
||
"""
|
||
Проверяет точное совпадение обоих проходов.
|
||
"""
|
||
|
||
for measurement_name in [
|
||
"base",
|
||
"roi",
|
||
"composite",
|
||
"source_indices",
|
||
]:
|
||
if (
|
||
first_measurements[measurement_name]
|
||
!= second_measurements[measurement_name]
|
||
):
|
||
raise RuntimeError(
|
||
"Проходы различаются по измерению: "
|
||
f"{measurement_name}."
|
||
)
|
||
|
||
if (
|
||
first_state.selected_composite_frames
|
||
!= second_state.selected_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(
|
||
"Итоговые состояния двух проходов не совпали."
|
||
)
|
||
|
||
|
||
def validate_statistics(
|
||
statistics: ProfileStatistics,
|
||
) -> None:
|
||
"""
|
||
Проверяет синхронизацию и арифметику итоговой строки.
|
||
"""
|
||
|
||
if statistics.selected_composite_frames <= 0:
|
||
raise RuntimeError("Число составных обновлений равно нулю.")
|
||
|
||
if (
|
||
statistics.timestamp_mismatch_count != 0
|
||
or statistics.frame_id_mismatch_count != 0
|
||
):
|
||
raise RuntimeError("Обнаружен mismatch синхронного профиля.")
|
||
|
||
if (
|
||
statistics.total_payload_bytes
|
||
!= statistics.total_base_bytes
|
||
+ statistics.total_roi_bytes
|
||
):
|
||
raise RuntimeError("Некорректная сумма payload bytes.")
|
||
|
||
|
||
def save_csv(statistics: 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()
|
||
writer.writerow(
|
||
{
|
||
"profile_name": statistics.profile_name,
|
||
"playback_speed_factor": (
|
||
f"{statistics.playback_speed_factor:.6f}"
|
||
),
|
||
"source_width": statistics.source_width,
|
||
"source_height": statistics.source_height,
|
||
"source_fps": f"{statistics.source_fps:.6f}",
|
||
"source_frames": statistics.source_frames,
|
||
"source_duration_s": (
|
||
f"{statistics.source_duration_s:.6f}"
|
||
),
|
||
"output_width": statistics.output_width,
|
||
"output_height": statistics.output_height,
|
||
"output_fps": f"{statistics.output_fps:.6f}",
|
||
"output_frames": statistics.output_frames,
|
||
"output_duration_s": (
|
||
f"{statistics.output_duration_s:.6f}"
|
||
),
|
||
"composite_fps": (
|
||
f"{statistics.composite_fps:.6f}"
|
||
),
|
||
"base_width": statistics.base_width,
|
||
"base_height": statistics.base_height,
|
||
"base_quality": statistics.base_quality,
|
||
"roi_width": statistics.roi_width,
|
||
"roi_height": statistics.roi_height,
|
||
"roi_quality": statistics.roi_quality,
|
||
"selected_composite_frames": (
|
||
statistics.selected_composite_frames
|
||
),
|
||
"total_base_bytes": statistics.total_base_bytes,
|
||
"total_roi_bytes": statistics.total_roi_bytes,
|
||
"total_payload_bytes": (
|
||
statistics.total_payload_bytes
|
||
),
|
||
"mean_base_frame_bytes": (
|
||
f"{statistics.mean_base_frame_bytes:.3f}"
|
||
),
|
||
"mean_roi_frame_bytes": (
|
||
f"{statistics.mean_roi_frame_bytes:.3f}"
|
||
),
|
||
"mean_composite_frame_bytes": (
|
||
f"{statistics.mean_composite_frame_bytes:.3f}"
|
||
),
|
||
"p95_composite_frame_bytes": (
|
||
f"{statistics.p95_composite_frame_bytes:.3f}"
|
||
),
|
||
"max_composite_frame_bytes": (
|
||
statistics.max_composite_frame_bytes
|
||
),
|
||
"base_bitrate_kbps": (
|
||
f"{statistics.base_bitrate_kbps:.6f}"
|
||
),
|
||
"roi_bitrate_kbps": (
|
||
f"{statistics.roi_bitrate_kbps:.6f}"
|
||
),
|
||
"total_payload_bitrate_kbps": (
|
||
f"{statistics.total_payload_bitrate_kbps:.6f}"
|
||
),
|
||
"channel_rate_fec_2_3_kbps": (
|
||
f"{statistics.channel_rate_fec_2_3_kbps:.6f}"
|
||
),
|
||
"channel_rate_fec_1_2_kbps": (
|
||
f"{statistics.channel_rate_fec_1_2_kbps:.6f}"
|
||
),
|
||
"timestamp_mismatch_count": (
|
||
statistics.timestamp_mismatch_count
|
||
),
|
||
"frame_id_mismatch_count": (
|
||
statistics.frame_id_mismatch_count
|
||
),
|
||
}
|
||
)
|
||
|
||
|
||
def write_report(
|
||
statistics: ProfileStatistics,
|
||
preview_path: Path,
|
||
fallback_used: bool,
|
||
) -> None:
|
||
"""
|
||
Сохраняет учебный отчёт без окончательного утверждения режима.
|
||
"""
|
||
|
||
lines = [
|
||
"Lab027E. Операторское preview при playback 0.5x",
|
||
"",
|
||
"Результаты ручной оценки Lab027D:",
|
||
"- 3 fps достаточно на исходном быстром видео;",
|
||
"- Q20/Q30 приемлем;",
|
||
"- Q23/Q33 приемлем;",
|
||
"- разница Q23/Q33 и Q25/Q35 практически отсутствует.",
|
||
"",
|
||
"Предварительно выбран профиль Q23/Q33.",
|
||
"От Q25/Q35 предварительно отказались, поскольку "
|
||
"визуальный выигрыш почти отсутствует.",
|
||
"Профиль не считается окончательно утверждённым до "
|
||
"ручного просмотра этого preview.",
|
||
"",
|
||
"Исходная сцена замедлена для имитации более медленного "
|
||
"движения ровера и оценки удобства управления.",
|
||
f"Коэффициент playback: "
|
||
f"{statistics.playback_speed_factor:.3f}x.",
|
||
"Предупреждение: реальная скорость исходного автомобиля "
|
||
"неизвестна.",
|
||
"",
|
||
"Исходное видео:",
|
||
f"- путь: {SOURCE_VIDEO_PATH};",
|
||
f"- разрешение: "
|
||
f"{statistics.source_width}x{statistics.source_height};",
|
||
f"- FPS: {statistics.source_fps:.6f};",
|
||
f"- кадров: {statistics.source_frames};",
|
||
f"- длительность: "
|
||
f"{statistics.source_duration_s:.6f} с.",
|
||
"",
|
||
"Выходное preview:",
|
||
f"- разрешение: "
|
||
f"{statistics.output_width}x{statistics.output_height};",
|
||
f"- FPS: {statistics.output_fps:.6f};",
|
||
f"- кадров: {statistics.output_frames};",
|
||
f"- длительность: "
|
||
f"{statistics.output_duration_s:.6f} с;",
|
||
f"- composite FPS: {statistics.composite_fps:.6f}.",
|
||
"",
|
||
"Профиль:",
|
||
f"- BASE {statistics.base_width}x"
|
||
f"{statistics.base_height} Q{statistics.base_quality};",
|
||
f"- ROI {statistics.roi_width}x"
|
||
f"{statistics.roi_height} Q{statistics.roi_quality};",
|
||
f"- составных обновлений: "
|
||
f"{statistics.selected_composite_frames}.",
|
||
"",
|
||
"Фактические скорости:",
|
||
f"- BASE: {statistics.base_bitrate_kbps:.6f} kbit/s;",
|
||
f"- ROI: {statistics.roi_bitrate_kbps:.6f} kbit/s;",
|
||
"- total payload: "
|
||
f"{statistics.total_payload_bitrate_kbps:.6f} kbit/s;",
|
||
"- channel rate FEC 2/3: "
|
||
f"{statistics.channel_rate_fec_2_3_kbps:.6f} kbit/s;",
|
||
"- channel rate FEC 1/2: "
|
||
f"{statistics.channel_rate_fec_1_2_kbps:.6f} kbit/s.",
|
||
"",
|
||
"Оценки канальной скорости иллюстративны и не являются "
|
||
"окончательной архитектурой радиоканала.",
|
||
"Mismatch-проверка:",
|
||
"- timestamp mismatch: "
|
||
f"{statistics.timestamp_mismatch_count};",
|
||
"- frame ID mismatch: "
|
||
f"{statistics.frame_id_mismatch_count}.",
|
||
"",
|
||
f"Preview: {preview_path}",
|
||
"Формат: "
|
||
+ ("AVI/MJPG fallback." if fallback_used else "MP4/mp4v."),
|
||
"Следующий шаг: ручная оценка удобства управления "
|
||
"пользователем.",
|
||
"",
|
||
]
|
||
|
||
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,
|
||
expected_frame_count: int,
|
||
expected_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 != expected_frame_count:
|
||
raise RuntimeError(
|
||
"Число кадров preview не совпало с расчётным."
|
||
)
|
||
|
||
if (
|
||
abs(duration_seconds - expected_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:
|
||
"""
|
||
Выполняет оба прохода Lab027E и проверяет результат.
|
||
"""
|
||
|
||
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)
|
||
profile = build_profile()
|
||
output_frame_count = calculate_output_frame_count(
|
||
source_frame_count,
|
||
source_fps,
|
||
OUTPUT_FPS,
|
||
profile.playback_speed_factor,
|
||
)
|
||
output_duration_seconds = (
|
||
output_frame_count / OUTPUT_FPS
|
||
)
|
||
|
||
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" Source duration: {source_duration_seconds:.6f} s")
|
||
print(
|
||
f" Playback speed: "
|
||
f"{profile.playback_speed_factor:.3f}x"
|
||
)
|
||
print(f" Output frames: {output_frame_count}")
|
||
print(f" Output duration: {output_duration_seconds:.6f} s")
|
||
|
||
print("First pass: measuring JPEG payload...")
|
||
first_measurements, first_state = process_first_pass(
|
||
SOURCE_VIDEO_PATH,
|
||
profile,
|
||
source_width,
|
||
source_height,
|
||
source_fps,
|
||
source_frame_count,
|
||
output_frame_count,
|
||
)
|
||
statistics = calculate_statistics(
|
||
profile,
|
||
first_measurements,
|
||
first_state,
|
||
source_width,
|
||
source_height,
|
||
source_fps,
|
||
source_frame_count,
|
||
source_duration_seconds,
|
||
output_frame_count,
|
||
)
|
||
validate_statistics(statistics)
|
||
|
||
print("Second pass: writing operator preview...")
|
||
(
|
||
preview_path,
|
||
fallback_used,
|
||
second_measurements,
|
||
second_state,
|
||
) = write_operator_preview(
|
||
SOURCE_VIDEO_PATH,
|
||
profile,
|
||
statistics,
|
||
source_width,
|
||
source_height,
|
||
source_fps,
|
||
source_frame_count,
|
||
output_frame_count,
|
||
)
|
||
compare_passes(
|
||
first_measurements,
|
||
second_measurements,
|
||
first_state,
|
||
second_state,
|
||
)
|
||
|
||
second_statistics = calculate_statistics(
|
||
profile,
|
||
second_measurements,
|
||
second_state,
|
||
source_width,
|
||
source_height,
|
||
source_fps,
|
||
source_frame_count,
|
||
source_duration_seconds,
|
||
output_frame_count,
|
||
)
|
||
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,
|
||
output_frame_count,
|
||
output_duration_seconds,
|
||
)
|
||
|
||
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||
save_csv(statistics)
|
||
write_report(
|
||
statistics,
|
||
preview_path,
|
||
fallback_used,
|
||
)
|
||
|
||
print("")
|
||
print("Measured profile:")
|
||
print(f" {statistics.profile_name}")
|
||
print(
|
||
" Composite updates: "
|
||
f"{statistics.selected_composite_frames}"
|
||
)
|
||
print(
|
||
f" BASE bitrate: "
|
||
f"{statistics.base_bitrate_kbps:.6f} kbit/s"
|
||
)
|
||
print(
|
||
f" ROI bitrate: "
|
||
f"{statistics.roi_bitrate_kbps:.6f} kbit/s"
|
||
)
|
||
print(
|
||
f" Total payload bitrate: "
|
||
f"{statistics.total_payload_bitrate_kbps:.6f} kbit/s"
|
||
)
|
||
print(
|
||
f" Channel rate FEC 2/3: "
|
||
f"{statistics.channel_rate_fec_2_3_kbps:.6f} kbit/s"
|
||
)
|
||
print(
|
||
f" Channel rate FEC 1/2: "
|
||
f"{statistics.channel_rate_fec_1_2_kbps:.6f} kbit/s"
|
||
)
|
||
print(
|
||
f" Timestamp mismatch: "
|
||
f"{statistics.timestamp_mismatch_count}"
|
||
)
|
||
print(
|
||
f" Frame ID mismatch: "
|
||
f"{statistics.frame_id_mismatch_count}"
|
||
)
|
||
print("")
|
||
print(
|
||
"Two-pass JPEG sizes, source indices 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"Preview 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("Lab027E completed successfully.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|