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

1862 lines
54 KiB
Python
Raw Permalink 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.
"""
Lab026B. Увеличение разрешения за счёт глубины цвета 4 бита.
Исследуются независимые кадры с 4-битной яркостью и 4-битным
индексированным цветом. Под индексированным цветом понимается один
4-битный индекс на пиксель и палитра не более чем из 16 цветов.
RGB444, потоковые видеокодеки и межкадровое кодирование не применяются.
Все промежуточные JPEG, PNG и zlib-представления находятся только
в оперативной памяти. Исходное видео открывается только для чтения.
"""
from __future__ import annotations
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
import csv
import math
import textwrap
import zlib
import cv2
import matplotlib
import numpy as np
from PIL import Image
matplotlib.use("Agg")
import matplotlib.pyplot as plt
# ---------------------------------------------------------------------
# Пути
# ---------------------------------------------------------------------
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
OUTPUT_DIRECTORY = Path("data/processed/lab026b")
CSV_PATH = OUTPUT_DIRECTORY / "lab026b_profiles.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab026b_report.txt"
RESOLUTION_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab026b_bitrate_vs_resolution.png"
)
CODEC_COMPARISON_PATH = (
OUTPUT_DIRECTORY / "lab026b_codec_comparison.png"
)
EQUAL_BITRATE_COMPARISON_PATH = (
OUTPUT_DIRECTORY / "lab026b_equal_bitrate_candidates.png"
)
EXPECTED_OUTPUT_PATHS = [
CSV_PATH,
REPORT_PATH,
RESOLUTION_PLOT_PATH,
CODEC_COMPARISON_PATH,
EQUAL_BITRATE_COMPARISON_PATH,
]
# ---------------------------------------------------------------------
# Параметры эксперимента
# ---------------------------------------------------------------------
EXPERIMENT_NAME = "four_bit_resolution_sweep"
TARGET_FPS = 2.0
BASELINE_BITRATE_KBPS = 80.283660
FRAME_TIME_EPSILON_SECONDS = 1e-9
FRAME_HEADER_BYTES = 16
FIXED_PALETTE_BYTES = 16 * 3
MAXIMUM_PSNR_DB = 100.0
RESOLUTIONS = [
(320, 180),
(400, 225),
(448, 252),
(480, 270),
(560, 315),
(640, 360),
]
MODE_GRAY8_JPEG = "gray8_jpeg"
MODE_GRAY4_JPEG = "gray4_jpeg"
MODE_GRAY4_PACKED_ZLIB = "gray4_packed_zlib"
MODE_COLOR16_PNG = "color16_png"
MODE_COLOR16_PACKED_ZLIB = "color16_packed_zlib"
PROFILE_MODES = [
MODE_GRAY8_JPEG,
MODE_GRAY4_JPEG,
MODE_GRAY4_PACKED_ZLIB,
MODE_COLOR16_PNG,
MODE_COLOR16_PACKED_ZLIB,
]
CSV_FIELD_NAMES = [
"experiment",
"profile_name",
"mode",
"width",
"height",
"pixel_count",
"target_fps",
"actual_fps",
"bits_per_pixel_before_compression",
"maximum_gray_levels",
"maximum_colors",
"selected_frames",
"total_payload_bytes",
"mean_frame_bytes",
"median_frame_bytes",
"p95_frame_bytes",
"max_frame_bytes",
"payload_bitrate_bps",
"payload_bitrate_kbps",
"baseline_bitrate_kbps",
"difference_from_baseline_kbps",
"percent_of_baseline_bitrate",
"compression_ratio_from_unpacked_source",
"mean_psnr_db",
"header_bytes_per_frame",
"palette_bytes_per_frame",
"notes",
]
@dataclass(frozen=True)
class FourBitProfile:
"""
Описывает разрешение и режим одного профиля Lab026B.
"""
experiment: str
profile_name: str
mode: str
width: int
height: int
target_fps: float
bits_per_pixel_before_compression: int
maximum_gray_levels: int
maximum_colors: int
header_bytes_per_frame: int
palette_bytes_per_frame: int
notes: str
@dataclass(frozen=True)
class FourBitResult:
"""
Содержит измеренные характеристики одного профиля Lab026B.
"""
experiment: str
profile_name: str
mode: str
width: int
height: int
pixel_count: int
target_fps: float
actual_fps: float
bits_per_pixel_before_compression: int
maximum_gray_levels: int
maximum_colors: int
selected_frames: int
total_payload_bytes: int
mean_frame_bytes: float
median_frame_bytes: float
p95_frame_bytes: float
max_frame_bytes: int
payload_bitrate_bps: float
payload_bitrate_kbps: float
baseline_bitrate_kbps: float
difference_from_baseline_kbps: float
percent_of_baseline_bitrate: float
compression_ratio_from_unpacked_source: float
mean_psnr_db: float
header_bytes_per_frame: int
palette_bytes_per_frame: int
notes: str
@dataclass
class ProfileAccumulator:
"""
Накапливает размеры кадров и PSNR при чтении исходного ролика.
"""
next_sample_time: float
payload_sizes: list[int]
psnr_values: list[float]
maximum_observed_levels: int
maximum_observed_colors: int
@dataclass(frozen=True)
class FrameEncodingResult:
"""
Описывает результат кодирования одного кадра в памяти.
"""
payload_size_bytes: int
restored_frame: np.ndarray
observed_gray_levels: int
observed_colors: int
def read_video_metadata(
source_path: Path,
) -> tuple[int, int, float, int, float, int]:
"""
Читает параметры исходного видео через OpenCV.
"""
if not source_path.is_file():
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("Получено некорректное разрешение видео.")
if source_fps <= 0.0:
raise RuntimeError("FPS исходного видео равен нулю.")
if frame_count <= 0:
raise RuntimeError("Число кадров исходного видео равно нулю.")
duration_seconds = frame_count / source_fps
source_size_bytes = source_path.stat().st_size
if source_size_bytes <= 0:
raise RuntimeError("Исходное видео имеет нулевой размер.")
return (
width,
height,
source_fps,
frame_count,
duration_seconds,
source_size_bytes,
)
def build_profiles() -> list[FourBitProfile]:
"""
Формирует 30 профилей: шесть разрешений и пять режимов.
"""
profiles: list[FourBitProfile] = []
mode_parameters = {
MODE_GRAY8_JPEG: {
"bits": 8,
"gray_levels": 256,
"colors": 0,
"header": 0,
"palette": 0,
"notes": "Grayscale 8 bit, JPEG quality 40.",
},
MODE_GRAY4_JPEG: {
"bits": 4,
"gray_levels": 16,
"colors": 0,
"header": 0,
"palette": 0,
"notes": (
"Grayscale quantized to 16 levels, "
"restored to uint8, JPEG quality 40."
),
},
MODE_GRAY4_PACKED_ZLIB: {
"bits": 4,
"gray_levels": 16,
"colors": 0,
"header": FRAME_HEADER_BYTES,
"palette": 0,
"notes": (
"Two 4-bit grayscale indices per byte, "
"zlib level 9, 16-byte frame header."
),
},
MODE_COLOR16_PNG: {
"bits": 4,
"gray_levels": 0,
"colors": 16,
"header": 0,
"palette": 0,
"notes": (
"Pillow palette image, maximum 16 colors, "
"no dithering, optimized indexed PNG."
),
},
MODE_COLOR16_PACKED_ZLIB: {
"bits": 4,
"gray_levels": 0,
"colors": 16,
"header": FRAME_HEADER_BYTES,
"palette": FIXED_PALETTE_BYTES,
"notes": (
"Two 4-bit palette indices per byte, "
"zlib level 9, 16-byte header and "
"fixed 48-byte palette."
),
},
}
for width, height in RESOLUTIONS:
for mode in PROFILE_MODES:
parameters = mode_parameters[mode]
profiles.append(
FourBitProfile(
experiment=EXPERIMENT_NAME,
profile_name=(
f"{width}x{height}_{mode}_2fps"
),
mode=mode,
width=width,
height=height,
target_fps=TARGET_FPS,
bits_per_pixel_before_compression=(
parameters["bits"]
),
maximum_gray_levels=(
parameters["gray_levels"]
),
maximum_colors=parameters["colors"],
header_bytes_per_frame=parameters["header"],
palette_bytes_per_frame=parameters["palette"],
notes=parameters["notes"],
)
)
return profiles
def should_sample_frame(
frame_time_seconds: float,
next_sample_time_seconds: float,
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
) -> bool:
"""
Выбирает кадры по временной шкале, как в основной Lab026.
"""
return (
frame_time_seconds + epsilon_seconds
>= next_sample_time_seconds
)
def quantize_gray4(
gray_frame: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""
Квантует яркость в индексы 0...15 и восстанавливает uint8.
"""
indices = np.rint(
gray_frame.astype(np.float64)
/ 255.0
* 15.0
).astype(np.uint8)
if int(np.min(indices)) < 0 or int(np.max(indices)) > 15:
raise RuntimeError("Индексы gray4 вышли из диапазона 0...15.")
if np.unique(indices).size > 16:
raise RuntimeError("Gray4 содержит более 16 уровней.")
restored = np.rint(
indices.astype(np.float64)
/ 15.0
* 255.0
).astype(np.uint8)
return indices, restored
def pack_nibbles(indices: np.ndarray) -> bytes:
"""
Упаковывает два 4-битных индекса в один байт.
Первый пиксель помещается в старшие четыре бита. При нечётном
количестве пикселей младший полубайт последнего байта равен нулю.
"""
flattened = np.asarray(
indices,
dtype=np.uint8,
).reshape(-1)
if flattened.size == 0:
raise RuntimeError("Нельзя упаковать пустой массив индексов.")
if int(np.min(flattened)) < 0 or int(np.max(flattened)) > 15:
raise RuntimeError(
"Перед упаковкой обнаружен индекс вне диапазона 0...15."
)
if flattened.size % 2 != 0:
flattened = np.pad(
flattened,
(0, 1),
mode="constant",
constant_values=0,
)
packed = (
(flattened[0::2] << 4)
| flattened[1::2]
).astype(np.uint8)
return packed.tobytes()
def unpack_nibbles(
packed_bytes: bytes,
original_pixel_count: int,
) -> np.ndarray:
"""
Восстанавливает исходную последовательность 4-битных индексов.
"""
if original_pixel_count <= 0:
raise RuntimeError(
"Число восстанавливаемых пикселей должно быть положительным."
)
packed = np.frombuffer(
packed_bytes,
dtype=np.uint8,
)
unpacked = np.empty(
packed.size * 2,
dtype=np.uint8,
)
unpacked[0::2] = packed >> 4
unpacked[1::2] = packed & 0x0F
if original_pixel_count > unpacked.size:
raise RuntimeError(
"Упакованных данных недостаточно для восстановления."
)
return unpacked[:original_pixel_count].copy()
def pillow_quantize_parameters() -> tuple[object, object]:
"""
Возвращает совместимые enum Pillow для Median Cut без дизеринга.
"""
quantize_enum = getattr(Image, "Quantize", None)
dither_enum = getattr(Image, "Dither", None)
method = (
quantize_enum.MEDIANCUT
if quantize_enum is not None
else Image.MEDIANCUT
)
dither = (
dither_enum.NONE
if dither_enum is not None
else Image.NONE
)
return method, dither
def quantize_color16(
rgb_frame: np.ndarray,
) -> tuple[Image.Image, np.ndarray, np.ndarray, np.ndarray]:
"""
Создаёт палитровое Pillow-изображение с максимум 16 цветами.
Возвращает изображение P, индексы, палитру 16x3 и восстановленный
RGB-кадр.
"""
method, dither = pillow_quantize_parameters()
source_image = Image.fromarray(rgb_frame, mode="RGB")
palette_image = source_image.quantize(
colors=16,
method=method,
dither=dither,
)
if palette_image.mode != "P":
raise RuntimeError(
"Pillow вернул не палитровое изображение режима P."
)
indices = np.asarray(
palette_image,
dtype=np.uint8,
)
if int(np.min(indices)) < 0 or int(np.max(indices)) > 15:
raise RuntimeError(
"Индексы color16 вышли из диапазона 0...15."
)
if np.unique(indices).size > 16:
raise RuntimeError("Color16 содержит более 16 цветов.")
raw_palette = palette_image.getpalette()
if raw_palette is None:
raise RuntimeError("Pillow не вернул палитру изображения.")
palette_values = list(raw_palette[:FIXED_PALETTE_BYTES])
if len(palette_values) < FIXED_PALETTE_BYTES:
palette_values.extend(
[0] * (FIXED_PALETTE_BYTES - len(palette_values))
)
palette_rgb = np.asarray(
palette_values,
dtype=np.uint8,
).reshape(16, 3)
restored_rgb = np.asarray(
palette_image.convert("RGB"),
dtype=np.uint8,
).copy()
return (
palette_image,
indices,
palette_rgb,
restored_rgb,
)
def encode_gray_jpeg(
gray_frame: np.ndarray,
jpeg_quality: int = 40,
) -> tuple[int, np.ndarray]:
"""
Кодирует grayscale JPEG в памяти и возвращает декодированный кадр.
"""
encoding_succeeded, encoded_jpeg = cv2.imencode(
".jpg",
gray_frame,
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
)
if not encoding_succeeded or encoded_jpeg is None:
raise RuntimeError("JPEG encoding завершился ошибкой.")
decoded_gray = cv2.imdecode(
encoded_jpeg,
cv2.IMREAD_GRAYSCALE,
)
if decoded_gray is None:
raise RuntimeError("JPEG decoding завершился ошибкой.")
if decoded_gray.shape != gray_frame.shape:
raise RuntimeError(
"Размер JPEG после декодирования не совпадает с эталоном."
)
return int(encoded_jpeg.size), decoded_gray
def encode_palette_png(
palette_image: Image.Image,
) -> tuple[int, np.ndarray]:
"""
Сохраняет палитровый PNG в BytesIO и декодирует его обратно в RGB.
"""
if palette_image.mode != "P":
raise RuntimeError("Для PNG ожидался палитровый режим P.")
buffer = BytesIO()
palette_image.save(
buffer,
format="PNG",
optimize=True,
)
png_bytes = buffer.getvalue()
if not png_bytes:
raise RuntimeError("Pillow создал пустой PNG payload.")
with Image.open(BytesIO(png_bytes)) as decoded_image:
if decoded_image.mode != "P":
raise RuntimeError(
"Сохранённый PNG перестал быть палитровым."
)
decoded_rgb = np.asarray(
decoded_image.convert("RGB"),
dtype=np.uint8,
).copy()
return len(png_bytes), decoded_rgb
def encode_packed_zlib(
indices: np.ndarray,
header_bytes: int,
palette_bytes: int,
) -> tuple[int, np.ndarray]:
"""
Упаковывает индексы по два на байт и сжимает zlib level 9.
Функция обязательно проверяет точное восстановление pack/unpack.
"""
original_shape = indices.shape
original_count = indices.size
packed_bytes = pack_nibbles(indices)
unpacked_flat = unpack_nibbles(
packed_bytes,
original_count,
)
original_flat = np.asarray(
indices,
dtype=np.uint8,
).reshape(-1)
if not np.array_equal(unpacked_flat, original_flat):
raise RuntimeError("Проверка pack/unpack завершилась ошибкой.")
compressed_payload = zlib.compress(
packed_bytes,
level=9,
)
if not compressed_payload:
raise RuntimeError("zlib создал пустой payload.")
total_payload_bytes = (
header_bytes
+ palette_bytes
+ len(compressed_payload)
)
unpacked_indices = unpacked_flat.reshape(original_shape)
return total_payload_bytes, unpacked_indices
def calculate_psnr(
reference_frame: np.ndarray,
restored_frame: np.ndarray,
) -> float:
"""
Рассчитывает PSNR в float64 и ограничивает идеальный случай 100 дБ.
"""
if reference_frame.shape != restored_frame.shape:
raise RuntimeError(
"PSNR нельзя рассчитать для кадров разных размеров."
)
difference = (
reference_frame.astype(np.float64)
- restored_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 process_frame_for_profile(
source_frame: np.ndarray,
profile: FourBitProfile,
) -> FrameEncodingResult:
"""
Применяет один из пяти фиксированных режимов к исходному кадру.
"""
resized_bgr = cv2.resize(
source_frame,
(profile.width, profile.height),
interpolation=cv2.INTER_AREA,
)
if profile.mode == MODE_GRAY8_JPEG:
reference_gray = cv2.cvtColor(
resized_bgr,
cv2.COLOR_BGR2GRAY,
)
payload_size, restored_gray = encode_gray_jpeg(
reference_gray,
jpeg_quality=40,
)
return FrameEncodingResult(
payload_size,
restored_gray,
int(np.unique(reference_gray).size),
0,
)
if profile.mode in {
MODE_GRAY4_JPEG,
MODE_GRAY4_PACKED_ZLIB,
}:
reference_gray = cv2.cvtColor(
resized_bgr,
cv2.COLOR_BGR2GRAY,
)
indices, quantized_gray = quantize_gray4(
reference_gray
)
observed_levels = int(np.unique(indices).size)
if profile.mode == MODE_GRAY4_JPEG:
payload_size, restored_gray = encode_gray_jpeg(
quantized_gray,
jpeg_quality=40,
)
else:
payload_size, unpacked_indices = (
encode_packed_zlib(
indices,
FRAME_HEADER_BYTES,
0,
)
)
restored_gray = np.rint(
unpacked_indices.astype(np.float64)
/ 15.0
* 255.0
).astype(np.uint8)
return FrameEncodingResult(
payload_size,
restored_gray,
observed_levels,
0,
)
if profile.mode in {
MODE_COLOR16_PNG,
MODE_COLOR16_PACKED_ZLIB,
}:
reference_rgb = cv2.cvtColor(
resized_bgr,
cv2.COLOR_BGR2RGB,
)
(
palette_image,
indices,
palette_rgb,
quantized_rgb,
) = quantize_color16(reference_rgb)
observed_colors = int(np.unique(indices).size)
if profile.mode == MODE_COLOR16_PNG:
payload_size, restored_rgb = encode_palette_png(
palette_image
)
else:
payload_size, unpacked_indices = (
encode_packed_zlib(
indices,
FRAME_HEADER_BYTES,
FIXED_PALETTE_BYTES,
)
)
restored_rgb = palette_rgb[unpacked_indices]
return FrameEncodingResult(
payload_size,
restored_rgb,
0,
observed_colors,
)
raise RuntimeError(f"Неизвестный режим: {profile.mode}")
def reference_frame_for_psnr(
source_frame: np.ndarray,
profile: FourBitProfile,
) -> np.ndarray:
"""
Создаёт эталон исходного resized-кадра для PSNR.
"""
resized_bgr = cv2.resize(
source_frame,
(profile.width, profile.height),
interpolation=cv2.INTER_AREA,
)
if profile.mode.startswith("gray"):
return cv2.cvtColor(
resized_bgr,
cv2.COLOR_BGR2GRAY,
)
return cv2.cvtColor(
resized_bgr,
cv2.COLOR_BGR2RGB,
)
def unpacked_reference_bytes(
profile: FourBitProfile,
) -> int:
"""
Возвращает размер эталонного массива до сжатия.
Для grayscale это один uint8 на пиксель, для RGB — три uint8.
"""
channel_count = (
1
if profile.mode.startswith("gray")
else 3
)
return profile.width * profile.height * channel_count
def process_profiles(
source_path: Path,
profiles: list[FourBitProfile],
source_fps: float,
source_frame_count: int,
source_duration_seconds: float,
) -> list[FourBitResult]:
"""
За один последовательный проход измеряет все 30 профилей.
"""
if len(profiles) != 30:
raise RuntimeError(
f"Ожидалось 30 профилей, получено {len(profiles)}."
)
accumulators = {
profile.profile_name: ProfileAccumulator(
next_sample_time=0.0,
payload_sizes=[],
psnr_values=[],
maximum_observed_levels=0,
maximum_observed_colors=0,
)
for profile in profiles
}
if len(accumulators) != len(profiles):
raise RuntimeError("Имена профилей должны быть уникальными.")
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}."
)
frame_time_seconds = frame_index / source_fps
for profile in profiles:
accumulator = accumulators[profile.profile_name]
if not should_sample_frame(
frame_time_seconds,
accumulator.next_sample_time,
):
continue
reference_frame = reference_frame_for_psnr(
source_frame,
profile,
)
frame_result = process_frame_for_profile(
source_frame,
profile,
)
psnr_db = calculate_psnr(
reference_frame,
frame_result.restored_frame,
)
accumulator.payload_sizes.append(
frame_result.payload_size_bytes
)
accumulator.psnr_values.append(psnr_db)
accumulator.maximum_observed_levels = max(
accumulator.maximum_observed_levels,
frame_result.observed_gray_levels,
)
accumulator.maximum_observed_colors = max(
accumulator.maximum_observed_colors,
frame_result.observed_colors,
)
accumulator.next_sample_time += (
1.0 / profile.target_fps
)
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[FourBitResult] = []
for profile in profiles:
accumulator = accumulators[profile.profile_name]
if not accumulator.payload_sizes:
raise RuntimeError(
f"Нет выбранных кадров: {profile.profile_name}"
)
payload_sizes = np.asarray(
accumulator.payload_sizes,
dtype=np.float64,
)
psnr_values = np.asarray(
accumulator.psnr_values,
dtype=np.float64,
)
selected_frames = len(accumulator.payload_sizes)
total_payload_bytes = int(
sum(accumulator.payload_sizes)
)
actual_fps = (
selected_frames / source_duration_seconds
)
payload_bitrate_bps = (
total_payload_bytes
* 8.0
/ source_duration_seconds
)
payload_bitrate_kbps = payload_bitrate_bps / 1000.0
difference_from_baseline_kbps = (
payload_bitrate_kbps
- BASELINE_BITRATE_KBPS
)
percent_of_baseline = (
payload_bitrate_kbps
/ BASELINE_BITRATE_KBPS
* 100.0
)
compression_ratio = (
unpacked_reference_bytes(profile)
/ float(np.mean(payload_sizes))
)
if profile.maximum_gray_levels == 16:
if accumulator.maximum_observed_levels > 16:
raise RuntimeError(
f"Gray4 превысил 16 уровней: {profile.profile_name}"
)
if profile.maximum_colors == 16:
if accumulator.maximum_observed_colors > 16:
raise RuntimeError(
f"Color16 превысил 16 цветов: {profile.profile_name}"
)
results.append(
FourBitResult(
experiment=profile.experiment,
profile_name=profile.profile_name,
mode=profile.mode,
width=profile.width,
height=profile.height,
pixel_count=profile.width * profile.height,
target_fps=profile.target_fps,
actual_fps=actual_fps,
bits_per_pixel_before_compression=(
profile.bits_per_pixel_before_compression
),
maximum_gray_levels=(
profile.maximum_gray_levels
),
maximum_colors=profile.maximum_colors,
selected_frames=selected_frames,
total_payload_bytes=total_payload_bytes,
mean_frame_bytes=float(np.mean(payload_sizes)),
median_frame_bytes=float(np.median(payload_sizes)),
p95_frame_bytes=float(
np.percentile(payload_sizes, 95)
),
max_frame_bytes=int(np.max(payload_sizes)),
payload_bitrate_bps=payload_bitrate_bps,
payload_bitrate_kbps=payload_bitrate_kbps,
baseline_bitrate_kbps=BASELINE_BITRATE_KBPS,
difference_from_baseline_kbps=(
difference_from_baseline_kbps
),
percent_of_baseline_bitrate=(
percent_of_baseline
),
compression_ratio_from_unpacked_source=(
compression_ratio
),
mean_psnr_db=float(np.mean(psnr_values)),
header_bytes_per_frame=(
profile.header_bytes_per_frame
),
palette_bytes_per_frame=(
profile.palette_bytes_per_frame
),
notes=profile.notes,
)
)
return results
def save_csv(results: list[FourBitResult]) -> None:
"""
Сохраняет 30 результатов Lab026B в 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(
{
"experiment": result.experiment,
"profile_name": result.profile_name,
"mode": result.mode,
"width": result.width,
"height": result.height,
"pixel_count": result.pixel_count,
"target_fps": f"{result.target_fps:.6f}",
"actual_fps": f"{result.actual_fps:.6f}",
"bits_per_pixel_before_compression": (
result.bits_per_pixel_before_compression
),
"maximum_gray_levels": (
result.maximum_gray_levels
),
"maximum_colors": result.maximum_colors,
"selected_frames": result.selected_frames,
"total_payload_bytes": (
result.total_payload_bytes
),
"mean_frame_bytes": (
f"{result.mean_frame_bytes:.3f}"
),
"median_frame_bytes": (
f"{result.median_frame_bytes:.3f}"
),
"p95_frame_bytes": (
f"{result.p95_frame_bytes:.3f}"
),
"max_frame_bytes": result.max_frame_bytes,
"payload_bitrate_bps": (
f"{result.payload_bitrate_bps:.3f}"
),
"payload_bitrate_kbps": (
f"{result.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_bitrate": (
f"{result.percent_of_baseline_bitrate:.6f}"
),
"compression_ratio_from_unpacked_source": (
f"{result.compression_ratio_from_unpacked_source:.6f}"
),
"mean_psnr_db": (
f"{result.mean_psnr_db:.6f}"
),
"header_bytes_per_frame": (
result.header_bytes_per_frame
),
"palette_bytes_per_frame": (
result.palette_bytes_per_frame
),
"notes": result.notes,
}
)
def find_candidates(
results: list[FourBitResult],
) -> dict[str, list[FourBitResult]]:
"""
Формирует заданные группы профилей по payload bitrate.
"""
return {
"70_to_90": [
result
for result in results
if 70.0 <= result.payload_bitrate_kbps <= 90.0
],
"up_to_baseline": [
result
for result in results
if (
result.payload_bitrate_kbps
<= BASELINE_BITRATE_KBPS
)
],
"up_to_100": [
result
for result in results
if result.payload_bitrate_kbps <= 100.0
],
"color16_up_to_100": [
result
for result in results
if (
result.mode.startswith("color16")
and result.payload_bitrate_kbps <= 100.0
)
],
"larger_than_320_up_to_baseline": [
result
for result in results
if (
result.pixel_count > 320 * 180
and result.payload_bitrate_kbps
<= BASELINE_BITRATE_KBPS
)
],
}
def maximum_resolution_results(
results: list[FourBitResult],
bitrate_limit_kbps: float,
) -> list[FourBitResult]:
"""
Возвращает все профили максимального разрешения до лимита.
"""
matching = [
result
for result in results
if result.payload_bitrate_kbps <= bitrate_limit_kbps
]
if not matching:
return []
maximum_pixel_count = max(
result.pixel_count
for result in matching
)
return [
result
for result in matching
if result.pixel_count == maximum_pixel_count
]
def annotate_bitrate_points(
axes: plt.Axes,
x_values: list[int],
y_values: list[float],
) -> None:
"""
Подписывает значения bitrate над точками.
"""
for x_value, y_value in zip(x_values, y_values):
axes.annotate(
f"{y_value:.1f}",
(x_value, y_value),
xytext=(0, 6),
textcoords="offset points",
ha="center",
fontsize=7,
)
def save_resolution_plot(
results: list[FourBitResult],
) -> None:
"""
Строит пять серий bitrate по разрешению и линию baseline.
"""
figure, axes = plt.subplots(figsize=(13, 7))
x_positions = list(range(len(RESOLUTIONS)))
resolution_labels = [
f"{width}×{height}"
for width, height in RESOLUTIONS
]
for mode in PROFILE_MODES:
mode_results = sorted(
(
result
for result in results
if result.mode == mode
),
key=lambda result: result.pixel_count,
)
y_values = [
result.payload_bitrate_kbps
for result in mode_results
]
axes.plot(
x_positions,
y_values,
marker="o",
label=mode,
)
annotate_bitrate_points(
axes,
x_positions,
y_values,
)
axes.axhline(
BASELINE_BITRATE_KBPS,
linestyle="--",
label=(
f"Baseline {BASELINE_BITRATE_KBPS:.6f} кбит/с"
),
)
axes.set_xticks(x_positions, resolution_labels)
axes.set_title(
"Lab026B. Битрейт 4-битных режимов по разрешению"
)
axes.set_xlabel("Разрешение кадра")
axes.set_ylabel("JPEG/PNG/zlib payload, кбит/с")
axes.grid(True)
axes.legend()
figure.tight_layout()
figure.savefig(RESOLUTION_PLOT_PATH, dpi=160)
plt.close(figure)
def save_codec_comparison(
results: list[FourBitResult],
) -> None:
"""
Строит столбчатое сравнение пяти режимов при 480x270.
"""
selected_results = [
result
for result in results
if result.width == 480 and result.height == 270
]
if len(selected_results) != 5:
raise RuntimeError(
"Для 480x270 ожидалось пять результатов."
)
labels = [result.mode for result in selected_results]
bitrates = [
result.payload_bitrate_kbps
for result in selected_results
]
figure, axes = plt.subplots(figsize=(12, 7))
bars = axes.bar(labels, bitrates)
for bar, result in zip(bars, selected_results):
axes.annotate(
(
f"{result.payload_bitrate_kbps:.1f} кбит/с\n"
f"PSNR {result.mean_psnr_db:.1f} дБ"
),
(
bar.get_x() + bar.get_width() / 2.0,
bar.get_height(),
),
xytext=(0, 5),
textcoords="offset points",
ha="center",
fontsize=8,
)
axes.axhline(
BASELINE_BITRATE_KBPS,
linestyle="--",
label=(
f"Baseline {BASELINE_BITRATE_KBPS:.6f} кбит/с"
),
)
axes.set_title("Lab026B. Сравнение режимов при 480×270")
axes.set_xlabel("Режим")
axes.set_ylabel("Payload bitrate, кбит/с")
axes.tick_params(axis="x", labelrotation=20)
axes.grid(True, axis="y")
axes.legend()
figure.tight_layout()
figure.savefig(CODEC_COMPARISON_PATH, dpi=160)
plt.close(figure)
def read_representative_frame(
source_path: Path,
source_frame_count: int,
) -> np.ndarray:
"""
Читает кадр приблизительно из середины исходного ролика.
"""
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(
f"OpenCV не смог открыть видео: {source_path}"
)
middle_index = source_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(
f"Не удалось прочитать средний кадр {middle_index}."
)
return frame
def result_by_profile(
results: list[FourBitResult],
width: int,
height: int,
mode: str,
) -> FourBitResult:
"""
Находит единственный результат по разрешению и режиму.
"""
matching = [
result
for result in results
if (
result.width == width
and result.height == height
and result.mode == mode
)
]
if len(matching) != 1:
raise RuntimeError(
f"Ожидался один результат {width}x{height} {mode}."
)
return matching[0]
def save_comparison_image(
source_path: Path,
source_frame_count: int,
profiles: list[FourBitProfile],
results: list[FourBitResult],
) -> None:
"""
Создаёт таблицу девяти кадров при близких битрейтах.
"""
result_lookup = {
(
result.width,
result.height,
result.mode,
): result
for result in results
}
profile_lookup = {
(
profile.width,
profile.height,
profile.mode,
): profile
for profile in profiles
}
panel_keys: list[tuple[int, int, str, str]] = [
(320, 180, MODE_GRAY8_JPEG, ""),
(448, 252, MODE_GRAY4_JPEG, ""),
(480, 270, MODE_GRAY4_JPEG, ""),
(448, 252, MODE_GRAY4_PACKED_ZLIB, ""),
(448, 252, MODE_COLOR16_PNG, ""),
(448, 252, MODE_COLOR16_PACKED_ZLIB, ""),
(480, 270, MODE_COLOR16_PNG, ""),
(480, 270, MODE_COLOR16_PACKED_ZLIB, ""),
]
best_candidates = [
result
for result in results
if result.payload_bitrate_kbps <= 100.0
]
if not best_candidates:
raise RuntimeError("Нет профиля с bitrate до 100 кбит/с.")
best_result = max(
best_candidates,
key=lambda result: (
result.pixel_count,
result.mean_psnr_db,
),
)
panel_keys.append(
(
best_result.width,
best_result.height,
best_result.mode,
"BEST <=100 kbps",
)
)
representative_frame = read_representative_frame(
source_path,
source_frame_count,
)
figure, axes_grid = plt.subplots(
3,
3,
figsize=(18, 13),
)
for axes, panel_key in zip(
axes_grid.ravel(),
panel_keys,
):
width, height, mode, special_label = panel_key
profile = profile_lookup[(width, height, mode)]
result = result_lookup[(width, height, mode)]
frame_result = process_frame_for_profile(
representative_frame,
profile,
)
displayed_frame = cv2.resize(
frame_result.restored_frame,
(640, 360),
interpolation=cv2.INTER_NEAREST,
)
if mode.startswith("gray"):
axes.imshow(
displayed_frame,
cmap="gray",
vmin=0,
vmax=255,
)
level_text = (
f"{profile.maximum_gray_levels} уровней"
)
else:
axes.imshow(displayed_frame)
level_text = (
f"{profile.maximum_colors} цветов"
)
title_prefix = (
f"{special_label}\n"
if special_label
else ""
)
axes.set_title(
title_prefix
+ textwrap.fill(mode, width=28)
+ "\n"
+ f"{width}×{height}, "
+ f"{result.payload_bitrate_kbps:.2f} кбит/с\n"
+ f"PSNR {result.mean_psnr_db:.2f} дБ, "
+ level_text,
fontsize=9,
)
axes.axis("off")
figure.suptitle(
"Lab026B. Сравнение 4-битных профилей",
fontsize=15,
)
figure.tight_layout()
figure.savefig(
EQUAL_BITRATE_COMPARISON_PATH,
dpi=160,
)
plt.close(figure)
def format_result_line(result: FourBitResult) -> str:
"""
Формирует полную строку результата для текстового отчёта.
"""
return (
f"{result.profile_name}: "
f"mode={result.mode}, "
f"{result.width}x{result.height}, "
f"frames={result.selected_frames}, "
f"actual_fps={result.actual_fps:.6f}, "
f"total={result.total_payload_bytes} bytes, "
f"mean={result.mean_frame_bytes:.3f}, "
f"median={result.median_frame_bytes:.3f}, "
f"p95={result.p95_frame_bytes:.3f}, "
f"max={result.max_frame_bytes}, "
f"bitrate={result.payload_bitrate_kbps:.6f} kbit/s, "
f"baseline_diff={result.difference_from_baseline_kbps:.6f}, "
f"baseline_percent={result.percent_of_baseline_bitrate:.3f}%, "
f"compression={result.compression_ratio_from_unpacked_source:.3f}x, "
f"PSNR={result.mean_psnr_db:.6f} dB"
)
def append_result_group(
report_lines: list[str],
title: str,
results: list[FourBitResult],
) -> None:
"""
Добавляет в отчёт именованную группу результатов.
"""
report_lines.extend(["", title])
if not results:
report_lines.append("Нет.")
return
for result in results:
report_lines.append(format_result_line(result))
def write_report(
source_width: int,
source_height: int,
source_fps: float,
source_frame_count: int,
source_duration_seconds: float,
source_size_bytes: int,
results: list[FourBitResult],
) -> None:
"""
Создаёт полный UTF-8 отчёт Lab026B.
"""
candidates = find_candidates(results)
maximum_to_baseline = maximum_resolution_results(
results,
BASELINE_BITRATE_KBPS,
)
maximum_to_100 = maximum_resolution_results(
results,
100.0,
)
gray4_results = [
result
for result in results
if result.mode.startswith("gray4")
]
color16_results = [
result
for result in results
if result.mode.startswith("color16")
]
report_lines = [
"Lab026B. Увеличение разрешения кадра "
"за счёт глубины цвета 4 бита",
"",
"Цель: проверить увеличение разрешения при битрейте, "
"близком к контрольным 80.283660 кбит/с.",
f"Исходное видео: {SOURCE_VIDEO_PATH}",
f"Размер исходного видео: {source_size_bytes} байт",
f"Исходное разрешение: {source_width}×{source_height}",
f"Исходный FPS: {source_fps:.6f}",
f"Число кадров: {source_frame_count}",
f"Длительность: {source_duration_seconds:.6f} с",
"",
"4-битная яркость: индекс 0...15, то есть 16 уровней "
"серого на пиксель.",
"4-битный индексированный цвет: один индекс 0...15 "
"на пиксель и палитра максимум из 16 RGB-цветов.",
"Это не RGB444: RGB444 требует по 4 бита на каждый "
"из трёх каналов, то есть 12 бит на пиксель.",
"",
"Режимы:",
"gray8_jpeg — контрольный grayscale JPEG Q40.",
"gray4_jpeg — 16 уровней серого, восстановление uint8, "
"JPEG Q40.",
"gray4_packed_zlib — 4-битные индексы, два на байт, "
"zlib level 9 и заголовок 16 байт.",
"color16_png — палитра максимум 16 цветов, без дизеринга, "
"индексированный PNG в памяти.",
"color16_packed_zlib — 4-битные индексы, zlib level 9, "
"заголовок 16 байт и фиксированная палитра 48 байт.",
"",
f"Контрольный baseline: {BASELINE_BITRATE_KBPS:.6f} кбит/с.",
"CRC, FEC, радиозаголовки и пакетирование пока не учтены.",
"Статические метрики не учитывают задержку видеоканала.",
"",
"Полные результаты 30 профилей:",
]
for result in results:
report_lines.append(format_result_line(result))
append_result_group(
report_lines,
"Профили 7090 кбит/с:",
candidates["70_to_90"],
)
append_result_group(
report_lines,
"Профили до baseline:",
candidates["up_to_baseline"],
)
append_result_group(
report_lines,
"Профили до 100 кбит/с:",
candidates["up_to_100"],
)
append_result_group(
report_lines,
"Цветные color16-профили до 100 кбит/с:",
candidates["color16_up_to_100"],
)
append_result_group(
report_lines,
(
"Профили больше 320x180 и не выше baseline:"
),
candidates["larger_than_320_up_to_baseline"],
)
append_result_group(
report_lines,
"Максимальное разрешение до baseline:",
maximum_to_baseline,
)
append_result_group(
report_lines,
"Максимальное разрешение до 100 кбит/с:",
maximum_to_100,
)
report_lines.extend(
[
"",
"Сравнение gray4 и color16:",
(
"Минимальный gray4 bitrate: "
f"{min(result.payload_bitrate_kbps for result in gray4_results):.6f} "
"кбит/с."
),
(
"Минимальный color16 bitrate: "
f"{min(result.payload_bitrate_kbps for result in color16_results):.6f} "
"кбит/с."
),
"Размер палитры packed color16: 48 байт на кадр.",
"Размер компактного заголовка packed-режимов: "
"16 байт на кадр.",
"",
"Нельзя автоматически объявлять профиль безопасным "
"для управления ровером по одной статической метрике.",
(
"Сравнительное изображение: "
f"{EQUAL_BITRATE_COMPARISON_PATH}"
),
"Следующий шаг: ручная визуальная оценка пользователем.",
]
)
REPORT_PATH.write_text(
"\n".join(report_lines),
encoding="utf-8",
)
def validate_output_files() -> None:
"""
Проверяет наличие и ненулевой размер пяти результатов.
"""
for path in EXPECTED_OUTPUT_PATHS:
if not path.is_file():
raise RuntimeError(f"Выходной файл не создан: {path}")
if path.stat().st_size <= 0:
raise RuntimeError(
f"Выходной файл имеет нулевой размер: {path}"
)
def main() -> None:
"""
Выполняет полный набор экспериментов Lab026B.
"""
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
print("Чтение метаданных исходного видео...")
(
source_width,
source_height,
source_fps,
source_frame_count,
source_duration_seconds,
source_size_bytes,
) = read_video_metadata(SOURCE_VIDEO_PATH)
print()
print("Исходные параметры:")
print(f" Путь: {SOURCE_VIDEO_PATH}")
print(f" Размер: {source_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()
print(f"Сформировано профилей: {len(profiles)}")
print("Последовательная обработка кадров...")
results = process_profiles(
source_path=SOURCE_VIDEO_PATH,
profiles=profiles,
source_fps=source_fps,
source_frame_count=source_frame_count,
source_duration_seconds=source_duration_seconds,
)
print("Сохранение CSV...")
save_csv(results)
print("Построение графика по разрешению...")
save_resolution_plot(results)
print("Построение сравнения режимов...")
save_codec_comparison(results)
print("Создание сравнительного изображения...")
save_comparison_image(
source_path=SOURCE_VIDEO_PATH,
source_frame_count=source_frame_count,
profiles=profiles,
results=results,
)
print("Создание текстового отчёта...")
write_report(
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_size_bytes=source_size_bytes,
results=results,
)
validate_output_files()
print()
print("Результаты профилей 320x180, 448x252 и 480x270:")
for result in results:
if (
(result.width, result.height)
in {(320, 180), (448, 252), (480, 270)}
):
print(f" {format_result_line(result)}")
print()
print("Созданы файлы:")
for path in EXPECTED_OUTPUT_PATHS:
print(f" {path} ({path.stat().st_size} байт)")
print()
print("Lab026B выполнена успешно.")
print(
"Для ручной оценки откройте: "
f"{EQUAL_BITRATE_COMPARISON_PATH}"
)
if __name__ == "__main__":
main()