Files
SDR-Rover/experiments/lab027_roi_multiresolution_benchmark.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

1711 lines
51 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.
"""
Lab027. Многомасштабная передача изображения:
общий кадр и фиксированная область интереса.
Лабораторная сравнивает контрольный низкоскоростной JPEG-поток
с восемью профилями, в которых независимо передаются:
- BASE — уменьшенный общий grayscale-кадр сцены;
- ROI — центральная нижняя область исходного кадра с отдельными
разрешением, частотой обновления и JPEG quality.
Все JPEG-кадры кодируются и декодируются только в памяти. Радиопротокол,
CRC, FEC, фрагментация и повторные передачи в расчёт не входят.
"""
from __future__ import annotations
import csv
import math
from dataclasses import dataclass, field
from pathlib import Path
import cv2
import matplotlib.pyplot as plt
import numpy as np
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
OUTPUT_DIRECTORY = Path("data/processed/lab027")
CSV_PATH = OUTPUT_DIRECTORY / "lab027_profiles.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab027_report.txt"
BITRATE_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab027_bitrate_comparison.png"
)
QUALITY_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab027_quality_comparison.png"
)
CANDIDATE_IMAGE_PATH = (
OUTPUT_DIRECTORY / "lab027_candidate_profiles.png"
)
EXPECTED_OUTPUT_PATHS = [
CSV_PATH,
REPORT_PATH,
BITRATE_PLOT_PATH,
QUALITY_PLOT_PATH,
CANDIDATE_IMAGE_PATH,
]
BASELINE_BITRATE_KBPS = 80.283660
FRAME_TIME_EPSILON_SECONDS = 1e-9
MAXIMUM_PSNR_DB = 100.0
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
RECONSTRUCTION_WIDTH = 640
RECONSTRUCTION_HEIGHT = 360
CSV_FIELD_NAMES = [
"profile_name",
"base_width",
"base_height",
"base_fps",
"base_quality",
"roi_enabled",
"roi_x_min",
"roi_y_min",
"roi_x_max",
"roi_y_max",
"roi_width",
"roi_height",
"roi_fps",
"roi_quality",
"source_duration_s",
"selected_base_frames",
"selected_roi_frames",
"actual_base_fps",
"actual_roi_fps",
"total_base_bytes",
"total_roi_bytes",
"total_payload_bytes",
"mean_base_frame_bytes",
"mean_roi_frame_bytes",
"p95_base_frame_bytes",
"p95_roi_frame_bytes",
"max_base_frame_bytes",
"max_roi_frame_bytes",
"base_bitrate_kbps",
"roi_bitrate_kbps",
"total_payload_bitrate_kbps",
"baseline_bitrate_kbps",
"difference_from_baseline_kbps",
"percent_of_baseline",
"mean_base_jpeg_psnr_db",
"mean_roi_jpeg_psnr_db",
"mean_reconstructed_full_psnr_db",
"mean_reconstructed_roi_psnr_db",
]
@dataclass(frozen=True)
class RoiProfile:
"""
Описывает один фиксированный профиль передачи BASE и ROI.
"""
profile_name: str
base_width: int
base_height: int
base_fps: float
base_quality: int
roi_enabled: bool
roi_width: int = 0
roi_height: int = 0
roi_fps: float = 0.0
roi_quality: int = 0
@dataclass(frozen=True)
class RoiResult:
"""
Хранит итоговые метрики одного профиля Lab027.
"""
profile_name: str
base_width: int
base_height: int
base_fps: float
base_quality: int
roi_enabled: bool
roi_x_min: float
roi_y_min: float
roi_x_max: float
roi_y_max: float
roi_width: int
roi_height: int
roi_fps: float
roi_quality: int
source_duration_s: float
selected_base_frames: int
selected_roi_frames: int
actual_base_fps: float
actual_roi_fps: float
total_base_bytes: int
total_roi_bytes: int
total_payload_bytes: int
mean_base_frame_bytes: float
mean_roi_frame_bytes: float
p95_base_frame_bytes: float
p95_roi_frame_bytes: float
max_base_frame_bytes: int
max_roi_frame_bytes: int
base_bitrate_kbps: float
roi_bitrate_kbps: float
total_payload_bitrate_kbps: float
baseline_bitrate_kbps: float
difference_from_baseline_kbps: float
percent_of_baseline: float
mean_base_jpeg_psnr_db: float
mean_roi_jpeg_psnr_db: float | None
mean_reconstructed_full_psnr_db: float
mean_reconstructed_roi_psnr_db: float | None
@dataclass
class ProfileAccumulator:
"""
Накапливает размеры, PSNR и последнее состояние потоков.
"""
next_base_time: float = 0.0
next_roi_time: float = 0.0
base_sizes_bytes: list[int] = field(default_factory=list)
roi_sizes_bytes: list[int] = field(default_factory=list)
base_jpeg_psnr_values_db: list[float] = field(
default_factory=list
)
roi_jpeg_psnr_values_db: list[float] = field(
default_factory=list
)
reconstructed_full_psnr_values_db: list[float] = field(
default_factory=list
)
reconstructed_roi_psnr_values_db: list[float] = field(
default_factory=list
)
latest_base_frame: np.ndarray | None = None
latest_roi_frame: np.ndarray | None = None
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))
source_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 source_fps <= 0.0:
raise RuntimeError("FPS исходного видео равен нулю.")
if frame_count <= 0:
raise RuntimeError("Число кадров исходного видео равно нулю.")
duration_seconds = frame_count / source_fps
file_size_bytes = source_path.stat().st_size
if duration_seconds <= 0.0 or file_size_bytes <= 0:
raise RuntimeError(
"Длительность или размер исходного видео некорректны."
)
return (
width,
height,
source_fps,
frame_count,
duration_seconds,
file_size_bytes,
)
def build_profiles() -> list[RoiProfile]:
"""
Создаёт ровно девять заданных в Lab027 профилей.
"""
profiles = [
RoiProfile(
"baseline_320x180_gray_2fps_q40",
320,
180,
2.0,
40,
False,
),
RoiProfile(
"base240_1fps_q25_roi320_2fps_q35",
240,
135,
1.0,
25,
True,
320,
180,
2.0,
35,
),
RoiProfile(
"base240_0_5fps_q25_roi320_2fps_q35",
240,
135,
0.5,
25,
True,
320,
180,
2.0,
35,
),
RoiProfile(
"base160_1fps_q25_roi320_2fps_q35",
160,
90,
1.0,
25,
True,
320,
180,
2.0,
35,
),
RoiProfile(
"base240_1fps_q25_roi448_1fps_q30",
240,
135,
1.0,
25,
True,
448,
252,
1.0,
30,
),
RoiProfile(
"base240_0_5fps_q25_roi448_1fps_q30",
240,
135,
0.5,
25,
True,
448,
252,
1.0,
30,
),
RoiProfile(
"base320_1fps_q30_roi320_1fps_q35",
320,
180,
1.0,
30,
True,
320,
180,
1.0,
35,
),
RoiProfile(
"base240_1fps_q20_roi320_1fps_q30",
240,
135,
1.0,
20,
True,
320,
180,
1.0,
30,
),
RoiProfile(
"base160_0_5fps_q20_roi320_2fps_q30",
160,
90,
0.5,
20,
True,
320,
180,
2.0,
30,
),
]
if len(profiles) != 9:
raise RuntimeError("Lab027 должна содержать ровно 9 профилей.")
if len({profile.profile_name for profile in profiles}) != 9:
raise RuntimeError("Имена профилей Lab027 не уникальны.")
return profiles
def normalized_roi_to_pixels(
width: int,
height: int,
) -> tuple[int, int, int, int]:
"""
Переводит фиксированные нормализованные границы ROI в пиксели.
Возвращается полуоткрытый прямоугольник:
x_min, y_min, x_max, y_max.
"""
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_sample_frame(
frame_time_seconds: float,
next_sample_time_seconds: float,
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
) -> bool:
"""
Проверяет наступление времени очередного независимого обновления.
"""
return (
frame_time_seconds + epsilon_seconds
>= next_sample_time_seconds
)
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 Lab027 должен получать только grayscale-кадр."
)
encoded_ok, encoded = cv2.imencode(
".jpg",
gray_frame,
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
)
if not encoded_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 calculate_psnr(
reference_frame: np.ndarray,
reconstructed_frame: np.ndarray,
) -> float:
"""
Рассчитывает PSNR в float64, используя 100 дБ при MSE=0.
"""
if reference_frame.shape != reconstructed_frame.shape:
raise RuntimeError(
"Нельзя рассчитать PSNR для разных размеров кадров."
)
difference = (
reference_frame.astype(np.float64)
- reconstructed_frame.astype(np.float64)
)
mean_squared_error = float(np.mean(difference ** 2))
if mean_squared_error == 0.0:
return MAXIMUM_PSNR_DB
return min(
10.0
* math.log10(
(255.0 ** 2) / mean_squared_error
),
MAXIMUM_PSNR_DB,
)
def reconstruct_frame(
decoded_base: np.ndarray,
decoded_roi: np.ndarray | None,
roi_enabled: bool,
reconstruction_roi: tuple[int, int, int, int],
) -> np.ndarray:
"""
Восстанавливает итоговый grayscale-кадр размером 640x360.
"""
reconstructed = cv2.resize(
decoded_base,
(RECONSTRUCTION_WIDTH, RECONSTRUCTION_HEIGHT),
interpolation=cv2.INTER_LINEAR,
)
if not roi_enabled:
return reconstructed
if decoded_roi is None:
raise RuntimeError(
"Для ROI-профиля отсутствует декодированное состояние ROI."
)
x_min, y_min, x_max, y_max = reconstruction_roi
roi_width = x_max - x_min
roi_height = y_max - y_min
resized_roi = cv2.resize(
decoded_roi,
(roi_width, roi_height),
interpolation=cv2.INTER_LINEAR,
)
reconstructed[y_min:y_max, x_min:x_max] = resized_roi
return reconstructed
def safe_mean(values: list[int]) -> float:
"""
Возвращает среднее списка либо ноль для отсутствующего ROI.
"""
if not values:
return 0.0
return float(np.mean(np.asarray(values, dtype=np.float64)))
def safe_percentile(values: list[int], percentile: float) -> float:
"""
Возвращает процентиль списка либо ноль для отсутствующего ROI.
"""
if not values:
return 0.0
return float(
np.percentile(
np.asarray(values, dtype=np.float64),
percentile,
)
)
def process_profiles(
source_path: Path,
profiles: list[RoiProfile],
source_width: int,
source_height: int,
source_fps: float,
source_frame_count: int,
source_duration_seconds: float,
) -> list[RoiResult]:
"""
Обрабатывает все профили за один последовательный проход по видео.
BASE и ROI обновляются независимо. Итоговая реконструкция оценивается
на каждом исходном кадре с удержанием последнего декодированного
состояния обоих потоков.
"""
if len(profiles) != 9:
raise RuntimeError("Ожидалось ровно 9 профилей Lab027.")
accumulators = {
profile.profile_name: ProfileAccumulator()
for profile in profiles
}
source_roi = normalized_roi_to_pixels(
source_width,
source_height,
)
reconstruction_roi = normalized_roi_to_pixels(
RECONSTRUCTION_WIDTH,
RECONSTRUCTION_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(
"Размер прочитанного кадра отличается от метаданных."
)
frame_time_seconds = frame_index / source_fps
source_gray = cv2.cvtColor(
source_frame,
cv2.COLOR_BGR2GRAY,
)
source_roi_gray = source_gray[
source_roi[1]:source_roi[3],
source_roi[0]:source_roi[2],
]
reference_full = cv2.resize(
source_gray,
(RECONSTRUCTION_WIDTH, RECONSTRUCTION_HEIGHT),
interpolation=cv2.INTER_AREA,
)
reference_roi = reference_full[
reconstruction_roi[1]:reconstruction_roi[3],
reconstruction_roi[0]:reconstruction_roi[2],
]
if source_roi_gray.size == 0 or reference_roi.size == 0:
raise RuntimeError("Рассчитана пустая область ROI.")
for profile in profiles:
accumulator = accumulators[profile.profile_name]
if should_sample_frame(
frame_time_seconds,
accumulator.next_base_time,
):
prepared_base = cv2.resize(
source_gray,
(profile.base_width, profile.base_height),
interpolation=cv2.INTER_AREA,
)
base_size, decoded_base = encode_decode_jpeg(
prepared_base,
profile.base_quality,
)
accumulator.base_sizes_bytes.append(base_size)
accumulator.base_jpeg_psnr_values_db.append(
calculate_psnr(
prepared_base,
decoded_base,
)
)
accumulator.latest_base_frame = decoded_base
accumulator.next_base_time += (
1.0 / profile.base_fps
)
if (
profile.roi_enabled
and should_sample_frame(
frame_time_seconds,
accumulator.next_roi_time,
)
):
prepared_roi = cv2.resize(
source_roi_gray,
(profile.roi_width, profile.roi_height),
interpolation=cv2.INTER_AREA,
)
roi_size, decoded_roi = encode_decode_jpeg(
prepared_roi,
profile.roi_quality,
)
accumulator.roi_sizes_bytes.append(roi_size)
accumulator.roi_jpeg_psnr_values_db.append(
calculate_psnr(
prepared_roi,
decoded_roi,
)
)
accumulator.latest_roi_frame = decoded_roi
accumulator.next_roi_time += (
1.0 / profile.roi_fps
)
if accumulator.latest_base_frame is None:
raise RuntimeError(
"BASE не был инициализирован при времени 0."
)
reconstructed = reconstruct_frame(
accumulator.latest_base_frame,
accumulator.latest_roi_frame,
profile.roi_enabled,
reconstruction_roi,
)
accumulator.reconstructed_full_psnr_values_db.append(
calculate_psnr(
reference_full,
reconstructed,
)
)
if profile.roi_enabled:
reconstructed_roi = reconstructed[
reconstruction_roi[1]:reconstruction_roi[3],
reconstruction_roi[0]:reconstruction_roi[2],
]
accumulator.reconstructed_roi_psnr_values_db.append(
calculate_psnr(
reference_roi,
reconstructed_roi,
)
)
frame_index += 1
if (
frame_index % 100 == 0
or frame_index == source_frame_count
):
print(
f" Обработано кадров: "
f"{frame_index}/{source_frame_count}"
)
finally:
capture.release()
if frame_index == 0:
raise RuntimeError("Не прочитан ни один кадр исходного видео.")
if frame_index != source_frame_count:
print(
"Предупреждение: фактическое число кадров "
f"{frame_index}, по метаданным {source_frame_count}."
)
results: list[RoiResult] = []
for profile in profiles:
accumulator = accumulators[profile.profile_name]
if not accumulator.base_sizes_bytes:
raise RuntimeError(
f"BASE не содержит кадров: {profile.profile_name}"
)
if (
profile.roi_enabled
and not accumulator.roi_sizes_bytes
):
raise RuntimeError(
f"ROI не содержит кадров: {profile.profile_name}"
)
selected_base_frames = len(
accumulator.base_sizes_bytes
)
selected_roi_frames = len(
accumulator.roi_sizes_bytes
)
total_base_bytes = int(
sum(accumulator.base_sizes_bytes)
)
total_roi_bytes = int(
sum(accumulator.roi_sizes_bytes)
)
total_payload_bytes = (
total_base_bytes + total_roi_bytes
)
actual_base_fps = (
selected_base_frames / source_duration_seconds
)
actual_roi_fps = (
selected_roi_frames / source_duration_seconds
)
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 total_payload_bitrate_kbps <= 0.0:
raise RuntimeError(
f"Неположительный битрейт: {profile.profile_name}"
)
if not math.isclose(
total_payload_bitrate_kbps,
base_bitrate_kbps + roi_bitrate_kbps,
rel_tol=1e-12,
abs_tol=1e-12,
):
raise RuntimeError(
"Суммарный битрейт не равен сумме BASE и ROI."
)
mean_roi_jpeg_psnr = (
float(
np.mean(
accumulator.roi_jpeg_psnr_values_db
)
)
if profile.roi_enabled
else None
)
mean_reconstructed_roi_psnr = (
float(
np.mean(
accumulator.reconstructed_roi_psnr_values_db
)
)
if profile.roi_enabled
else None
)
results.append(
RoiResult(
profile_name=profile.profile_name,
base_width=profile.base_width,
base_height=profile.base_height,
base_fps=profile.base_fps,
base_quality=profile.base_quality,
roi_enabled=profile.roi_enabled,
roi_x_min=(
ROI_X_MIN if profile.roi_enabled else 0.0
),
roi_y_min=(
ROI_Y_MIN if profile.roi_enabled else 0.0
),
roi_x_max=(
ROI_X_MAX if profile.roi_enabled else 0.0
),
roi_y_max=(
ROI_Y_MAX if profile.roi_enabled else 0.0
),
roi_width=(
profile.roi_width
if profile.roi_enabled
else 0
),
roi_height=(
profile.roi_height
if profile.roi_enabled
else 0
),
roi_fps=(
profile.roi_fps
if profile.roi_enabled
else 0.0
),
roi_quality=(
profile.roi_quality
if profile.roi_enabled
else 0
),
source_duration_s=source_duration_seconds,
selected_base_frames=selected_base_frames,
selected_roi_frames=selected_roi_frames,
actual_base_fps=actual_base_fps,
actual_roi_fps=actual_roi_fps,
total_base_bytes=total_base_bytes,
total_roi_bytes=total_roi_bytes,
total_payload_bytes=total_payload_bytes,
mean_base_frame_bytes=safe_mean(
accumulator.base_sizes_bytes
),
mean_roi_frame_bytes=safe_mean(
accumulator.roi_sizes_bytes
),
p95_base_frame_bytes=safe_percentile(
accumulator.base_sizes_bytes,
95,
),
p95_roi_frame_bytes=safe_percentile(
accumulator.roi_sizes_bytes,
95,
),
max_base_frame_bytes=max(
accumulator.base_sizes_bytes
),
max_roi_frame_bytes=(
max(accumulator.roi_sizes_bytes)
if accumulator.roi_sizes_bytes
else 0
),
base_bitrate_kbps=base_bitrate_kbps,
roi_bitrate_kbps=roi_bitrate_kbps,
total_payload_bitrate_kbps=(
total_payload_bitrate_kbps
),
baseline_bitrate_kbps=BASELINE_BITRATE_KBPS,
difference_from_baseline_kbps=(
total_payload_bitrate_kbps
- BASELINE_BITRATE_KBPS
),
percent_of_baseline=(
total_payload_bitrate_kbps
/ BASELINE_BITRATE_KBPS
* 100.0
),
mean_base_jpeg_psnr_db=float(
np.mean(
accumulator.base_jpeg_psnr_values_db
)
),
mean_roi_jpeg_psnr_db=mean_roi_jpeg_psnr,
mean_reconstructed_full_psnr_db=float(
np.mean(
accumulator
.reconstructed_full_psnr_values_db
)
),
mean_reconstructed_roi_psnr_db=(
mean_reconstructed_roi_psnr
),
)
)
if len(results) != 9:
raise RuntimeError("Создано не 9 результатов Lab027.")
return results
def optional_float_text(value: float | None) -> str:
"""
Представляет необязательное значение PSNR для CSV и отчёта.
"""
if value is None:
return ""
return f"{value:.6f}"
def save_csv(results: list[RoiResult]) -> None:
"""
Сохраняет девять результатов Lab027 в 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 result in results:
writer.writerow(
{
"profile_name": result.profile_name,
"base_width": result.base_width,
"base_height": result.base_height,
"base_fps": f"{result.base_fps:.6f}",
"base_quality": result.base_quality,
"roi_enabled": int(result.roi_enabled),
"roi_x_min": f"{result.roi_x_min:.6f}",
"roi_y_min": f"{result.roi_y_min:.6f}",
"roi_x_max": f"{result.roi_x_max:.6f}",
"roi_y_max": f"{result.roi_y_max:.6f}",
"roi_width": result.roi_width,
"roi_height": result.roi_height,
"roi_fps": f"{result.roi_fps:.6f}",
"roi_quality": result.roi_quality,
"source_duration_s": (
f"{result.source_duration_s:.6f}"
),
"selected_base_frames": (
result.selected_base_frames
),
"selected_roi_frames": (
result.selected_roi_frames
),
"actual_base_fps": (
f"{result.actual_base_fps:.6f}"
),
"actual_roi_fps": (
f"{result.actual_roi_fps:.6f}"
),
"total_base_bytes": result.total_base_bytes,
"total_roi_bytes": result.total_roi_bytes,
"total_payload_bytes": (
result.total_payload_bytes
),
"mean_base_frame_bytes": (
f"{result.mean_base_frame_bytes:.3f}"
),
"mean_roi_frame_bytes": (
f"{result.mean_roi_frame_bytes:.3f}"
),
"p95_base_frame_bytes": (
f"{result.p95_base_frame_bytes:.3f}"
),
"p95_roi_frame_bytes": (
f"{result.p95_roi_frame_bytes:.3f}"
),
"max_base_frame_bytes": (
result.max_base_frame_bytes
),
"max_roi_frame_bytes": (
result.max_roi_frame_bytes
),
"base_bitrate_kbps": (
f"{result.base_bitrate_kbps:.6f}"
),
"roi_bitrate_kbps": (
f"{result.roi_bitrate_kbps:.6f}"
),
"total_payload_bitrate_kbps": (
f"{result.total_payload_bitrate_kbps:.6f}"
),
"baseline_bitrate_kbps": (
f"{result.baseline_bitrate_kbps:.6f}"
),
"difference_from_baseline_kbps": (
f"{result.difference_from_baseline_kbps:.6f}"
),
"percent_of_baseline": (
f"{result.percent_of_baseline:.6f}"
),
"mean_base_jpeg_psnr_db": (
f"{result.mean_base_jpeg_psnr_db:.6f}"
),
"mean_roi_jpeg_psnr_db": optional_float_text(
result.mean_roi_jpeg_psnr_db
),
"mean_reconstructed_full_psnr_db": (
f"{result.mean_reconstructed_full_psnr_db:.6f}"
),
"mean_reconstructed_roi_psnr_db": (
optional_float_text(
result
.mean_reconstructed_roi_psnr_db
)
),
}
)
def short_profile_label(profile_name: str) -> str:
"""
Создаёт компактную многострочную подпись профиля для графиков.
"""
return profile_name.replace("_roi", "\nroi", 1)
def save_bitrate_plot(results: list[RoiResult]) -> None:
"""
Строит составные столбцы BASE/ROI и контрольные линии битрейта.
"""
x_positions = np.arange(len(results))
base_values = [
result.base_bitrate_kbps
for result in results
]
roi_values = [
result.roi_bitrate_kbps
for result in results
]
figure, axes = plt.subplots(figsize=(15, 8))
axes.bar(
x_positions,
base_values,
label="BASE bitrate",
)
axes.bar(
x_positions,
roi_values,
bottom=base_values,
label="ROI bitrate",
)
axes.axhline(
BASELINE_BITRATE_KBPS,
linestyle="--",
label=f"Baseline {BASELINE_BITRATE_KBPS:.6f} kbps",
)
axes.axhline(
100.0,
linestyle=":",
label="100 kbps",
)
for position, result in zip(x_positions, results):
axes.annotate(
f"{result.total_payload_bitrate_kbps:.1f}",
(
position,
result.total_payload_bitrate_kbps,
),
xytext=(0, 5),
textcoords="offset points",
ha="center",
fontsize=8,
)
axes.set_xticks(
x_positions,
[
short_profile_label(result.profile_name)
for result in results
],
rotation=35,
ha="right",
)
axes.set_ylabel("Полезный битрейт, кбит/с")
axes.set_title(
"Lab027. Распределение payload bitrate между BASE и ROI"
)
axes.legend()
axes.grid(True, axis="y")
figure.tight_layout()
figure.savefig(BITRATE_PLOT_PATH, dpi=160)
plt.close(figure)
def save_quality_plot(results: list[RoiResult]) -> None:
"""
Сравнивает средний PSNR полной реконструкции и области ROI.
"""
x_positions = np.arange(len(results))
bar_width = 0.38
full_values = [
result.mean_reconstructed_full_psnr_db
for result in results
]
roi_values = [
(
result.mean_reconstructed_roi_psnr_db
if result.mean_reconstructed_roi_psnr_db is not None
else np.nan
)
for result in results
]
figure, axes = plt.subplots(figsize=(15, 8))
axes.bar(
x_positions - bar_width / 2,
full_values,
width=bar_width,
label="Full-frame PSNR",
)
axes.bar(
x_positions + bar_width / 2,
roi_values,
width=bar_width,
label="ROI PSNR",
)
axes.set_xticks(
x_positions,
[
short_profile_label(result.profile_name)
for result in results
],
rotation=35,
ha="right",
)
axes.set_ylabel("Средний PSNR реконструкции, дБ")
axes.set_title(
"Lab027. Качество полной реконструкции и области ROI"
)
axes.legend()
axes.grid(True, axis="y")
figure.tight_layout()
figure.savefig(QUALITY_PLOT_PATH, dpi=160)
plt.close(figure)
def read_representative_frame(
source_path: Path,
frame_count: int,
) -> np.ndarray:
"""
Читает кадр около середины ролика для сравнительного изображения.
"""
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(
f"OpenCV не смог открыть видео: {source_path}"
)
middle_index = frame_count // 2
try:
capture.set(
cv2.CAP_PROP_POS_FRAMES,
middle_index,
)
frame_read, frame = capture.read()
finally:
capture.release()
if not frame_read or frame is None:
raise RuntimeError(
"Не удалось прочитать репрезентативный кадр."
)
return frame
def representative_reconstruction(
source_frame: np.ndarray,
profile: RoiProfile,
source_roi: tuple[int, int, int, int],
reconstruction_roi: tuple[int, int, int, int],
) -> np.ndarray:
"""
Кодирует BASE/ROI репрезентативного кадра и реконструирует его.
"""
source_gray = cv2.cvtColor(
source_frame,
cv2.COLOR_BGR2GRAY,
)
prepared_base = cv2.resize(
source_gray,
(profile.base_width, profile.base_height),
interpolation=cv2.INTER_AREA,
)
_, decoded_base = encode_decode_jpeg(
prepared_base,
profile.base_quality,
)
decoded_roi: np.ndarray | None = None
if profile.roi_enabled:
source_roi_gray = source_gray[
source_roi[1]:source_roi[3],
source_roi[0]:source_roi[2],
]
prepared_roi = cv2.resize(
source_roi_gray,
(profile.roi_width, profile.roi_height),
interpolation=cv2.INTER_AREA,
)
_, decoded_roi = encode_decode_jpeg(
prepared_roi,
profile.roi_quality,
)
return reconstruct_frame(
decoded_base,
decoded_roi,
profile.roi_enabled,
reconstruction_roi,
)
def save_candidate_image(
source_path: Path,
profiles: list[RoiProfile],
results: list[RoiResult],
source_width: int,
source_height: int,
source_frame_count: int,
) -> None:
"""
Создаёт девять панелей реконструкции репрезентативного кадра.
"""
source_frame = read_representative_frame(
source_path,
source_frame_count,
)
source_roi = normalized_roi_to_pixels(
source_width,
source_height,
)
reconstruction_roi = normalized_roi_to_pixels(
RECONSTRUCTION_WIDTH,
RECONSTRUCTION_HEIGHT,
)
result_by_name = {
result.profile_name: result
for result in results
}
figure, axes = plt.subplots(
3,
3,
figsize=(18, 12),
)
for axes_item, profile in zip(axes.flat, profiles):
result = result_by_name[profile.profile_name]
reconstructed = representative_reconstruction(
source_frame,
profile,
source_roi,
reconstruction_roi,
)
displayed = reconstructed.copy()
cv2.rectangle(
displayed,
(
reconstruction_roi[0],
reconstruction_roi[1],
),
(
reconstruction_roi[2] - 1,
reconstruction_roi[3] - 1,
),
255,
2,
)
if result.mean_reconstructed_roi_psnr_db is None:
roi_psnr_text = "ROI отсутствует"
else:
roi_psnr_text = (
"ROI PSNR="
f"{result.mean_reconstructed_roi_psnr_db:.2f} dB"
)
axes_item.imshow(
displayed,
cmap="gray",
vmin=0,
vmax=255,
interpolation="nearest",
)
axes_item.set_title(
f"{profile.profile_name}\n"
f"total={result.total_payload_bitrate_kbps:.2f} kbps, "
f"BASE={result.base_bitrate_kbps:.2f}, "
f"ROI={result.roi_bitrate_kbps:.2f}\n"
f"full PSNR="
f"{result.mean_reconstructed_full_psnr_db:.2f} dB, "
f"{roi_psnr_text}",
fontsize=8,
)
axes_item.axis("off")
figure.suptitle(
"Lab027. Общий кадр и фиксированная область интереса",
fontsize=14,
)
figure.tight_layout()
figure.savefig(CANDIDATE_IMAGE_PATH, dpi=160)
plt.close(figure)
def format_result_line(result: RoiResult) -> str:
"""
Формирует подробную строку одного результата для отчёта.
"""
roi_jpeg_psnr = (
f"{result.mean_roi_jpeg_psnr_db:.6f}"
if result.mean_roi_jpeg_psnr_db is not None
else "нет"
)
reconstructed_roi_psnr = (
f"{result.mean_reconstructed_roi_psnr_db:.6f}"
if result.mean_reconstructed_roi_psnr_db is not None
else "нет"
)
return (
f"{result.profile_name}: "
f"BASE={result.base_width}x{result.base_height}, "
f"{result.base_fps:.3f} fps, Q{result.base_quality}, "
f"base_frames={result.selected_base_frames}, "
f"base={result.base_bitrate_kbps:.6f} kbit/s; "
f"ROI="
f"{result.roi_width}x{result.roi_height}, "
f"{result.roi_fps:.3f} fps, Q{result.roi_quality}, "
f"roi_frames={result.selected_roi_frames}, "
f"roi={result.roi_bitrate_kbps:.6f} kbit/s; "
f"total={result.total_payload_bitrate_kbps:.6f} kbit/s, "
f"baseline_diff="
f"{result.difference_from_baseline_kbps:.6f}, "
f"baseline_percent={result.percent_of_baseline:.3f}%, "
f"BASE_JPEG_PSNR="
f"{result.mean_base_jpeg_psnr_db:.6f} dB, "
f"ROI_JPEG_PSNR={roi_jpeg_psnr} dB, "
f"full_reconstructed_PSNR="
f"{result.mean_reconstructed_full_psnr_db:.6f} dB, "
f"ROI_reconstructed_PSNR="
f"{reconstructed_roi_psnr} dB"
)
def append_result_group(
lines: list[str],
heading: str,
results: list[RoiResult],
) -> None:
"""
Добавляет в отчёт группу результатов либо отметку об отсутствии.
"""
lines.append(heading)
if not results:
lines.append("Нет.")
else:
lines.extend(format_result_line(result) for result in results)
lines.append("")
def write_report(
results: list[RoiResult],
source_width: int,
source_height: int,
source_fps: float,
source_frame_count: int,
source_duration_seconds: float,
source_file_size_bytes: int,
) -> None:
"""
Сохраняет подробный UTF-8 отчёт Lab027.
"""
under_baseline = [
result
for result in results
if result.total_payload_bitrate_kbps
<= BASELINE_BITRATE_KBPS + 1e-9
]
under_100 = [
result
for result in results
if result.total_payload_bitrate_kbps <= 100.0
]
roi_under_100 = [
result
for result in under_100
if result.roi_enabled
]
maximum_roi_resolution = (
max(
roi_under_100,
key=lambda item: (
item.roi_width * item.roi_height,
item.mean_reconstructed_roi_psnr_db
if item.mean_reconstructed_roi_psnr_db is not None
else -math.inf,
),
)
if roi_under_100
else None
)
best_roi_psnr = (
max(
roi_under_100,
key=lambda item: (
item.mean_reconstructed_roi_psnr_db
if item.mean_reconstructed_roi_psnr_db is not None
else -math.inf
),
)
if roi_under_100
else None
)
best_full_psnr = (
max(
under_100,
key=lambda item: (
item.mean_reconstructed_full_psnr_db
),
)
if under_100
else None
)
source_roi = normalized_roi_to_pixels(
source_width,
source_height,
)
lines = [
"Lab027. Многомасштабная передача изображения: "
"общий кадр и область интереса",
"",
"Цель: проверить совместную передачу общего кадра и "
"фиксированной ROI при payload около 80100 кбит/с.",
f"Исходное видео: {SOURCE_VIDEO_PATH}",
f"Размер файла: {source_file_size_bytes} байт",
f"Исходное разрешение: {source_width}×{source_height}",
f"Исходный FPS: {source_fps:.6f}",
f"Число кадров: {source_frame_count}",
f"Длительность: {source_duration_seconds:.6f} с",
"",
"BASE: полный grayscale-кадр пониженного разрешения "
"с собственной частотой и JPEG quality.",
"ROI: фиксированная центральная нижняя область исходного "
"grayscale-кадра с независимыми разрешением, частотой "
"и JPEG quality.",
"Нормализованные координаты ROI: "
f"x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}.",
"Пиксельные координаты ROI исходного кадра: "
f"x={source_roi[0]}...{source_roi[2]}, "
f"y={source_roi[1]}...{source_roi[3]}.",
"",
"Контрольный профиль: 320×180 grayscale, 2 fps, "
"JPEG Q40.",
f"Контрольный baseline: {BASELINE_BITRATE_KBPS:.6f} "
"кбит/с.",
"",
"Результаты всех девяти профилей:",
]
lines.extend(format_result_line(result) for result in results)
lines.append("")
append_result_group(
lines,
"Профили до 80.283660 кбит/с:",
under_baseline,
)
append_result_group(
lines,
"Профили до 100 кбит/с:",
under_100,
)
append_result_group(
lines,
"Профиль с максимальным ROI-разрешением до 100 кбит/с:",
(
[maximum_roi_resolution]
if maximum_roi_resolution is not None
else []
),
)
append_result_group(
lines,
"Профиль с максимальным ROI PSNR до 100 кбит/с:",
[best_roi_psnr] if best_roi_psnr is not None else [],
)
append_result_group(
lines,
"Профиль с максимальным full-frame PSNR "
"до 100 кбит/с:",
[best_full_psnr] if best_full_psnr is not None else [],
)
lines.append("Разделение битрейта BASE/ROI:")
for result in results:
lines.append(
f"{result.profile_name}: "
f"BASE={result.base_bitrate_kbps:.6f} кбит/с, "
f"ROI={result.roi_bitrate_kbps:.6f} кбит/с, "
f"total={result.total_payload_bitrate_kbps:.6f} "
"кбит/с."
)
lines.extend(
[
"",
"Фиксированная ROI не гарантирует попадания препятствия "
"в область интереса.",
"PSNR не заменяет ручную оценку пригодности изображения.",
"Нельзя автоматически объявлять профиль безопасным "
"для управления ровером.",
"Радиопротокол, радиозаголовки, CRC, FEC, "
"фрагментация и повторные передачи не учтены.",
f"Сравнительное изображение: {CANDIDATE_IMAGE_PATH}",
"Следующий шаг: ручная оценка пользователем.",
"",
]
)
REPORT_PATH.write_text(
"\n".join(lines),
encoding="utf-8",
)
def validate_output_files() -> None:
"""
Проверяет наличие и ненулевой размер пяти результатов Lab027.
"""
for path in EXPECTED_OUTPUT_PATHS:
if not path.exists():
raise RuntimeError(
f"Не создан ожидаемый результат: {path}"
)
if path.stat().st_size <= 0:
raise RuntimeError(
f"Создан пустой результат: {path}"
)
def main() -> None:
"""
Выполняет полный эксперимент Lab027.
"""
print("Проверка исходного видео Lab027...")
(
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_VIDEO_PATH}")
print(f" Размер: {source_file_size_bytes} байт")
print(
f" Разрешение: {source_width}x{source_height}"
)
print(f" FPS: {source_fps:.6f}")
print(f" Кадров: {source_frame_count}")
print(f" Длительность: {source_duration_seconds:.6f} с")
profiles = build_profiles()
print(f"Профилей: {len(profiles)}")
OUTPUT_DIRECTORY.mkdir(
parents=True,
exist_ok=True,
)
print("Обработка BASE и ROI...")
results = process_profiles(
source_path=SOURCE_VIDEO_PATH,
profiles=profiles,
source_width=source_width,
source_height=source_height,
source_fps=source_fps,
source_frame_count=source_frame_count,
source_duration_seconds=source_duration_seconds,
)
print("Сохранение CSV...")
save_csv(results)
print("Построение графика битрейта...")
save_bitrate_plot(results)
print("Построение графика качества...")
save_quality_plot(results)
print("Создание сравнительного изображения...")
save_candidate_image(
source_path=SOURCE_VIDEO_PATH,
profiles=profiles,
results=results,
source_width=source_width,
source_height=source_height,
source_frame_count=source_frame_count,
)
print("Сохранение отчёта...")
write_report(
results=results,
source_width=source_width,
source_height=source_height,
source_fps=source_fps,
source_frame_count=source_frame_count,
source_duration_seconds=source_duration_seconds,
source_file_size_bytes=source_file_size_bytes,
)
validate_output_files()
print("")
print("Результаты:")
for result in results:
print(
f" {result.profile_name}: "
f"BASE={result.base_bitrate_kbps:.6f}, "
f"ROI={result.roi_bitrate_kbps:.6f}, "
f"total={result.total_payload_bitrate_kbps:.6f} "
"кбит/с, "
f"full PSNR="
f"{result.mean_reconstructed_full_psnr_db:.6f} дБ, "
"ROI PSNR="
+ (
f"{result.mean_reconstructed_roi_psnr_db:.6f} дБ"
if result.mean_reconstructed_roi_psnr_db
is not None
else "нет"
)
)
print("")
for path in EXPECTED_OUTPUT_PATHS:
print(f" {path} ({path.stat().st_size} байт)")
print("")
print("Lab027 завершена успешно.")
if __name__ == "__main__":
main()