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>
926 lines
24 KiB
Python
926 lines
24 KiB
Python
"""
|
||
Lab027. Динамическое превью временного обновления BASE и ROI.
|
||
|
||
Скрипт не повторяет расчёт benchmark Lab027 и не изменяет его
|
||
результаты. Он создаёт одно игнорируемое Git preview-видео с четырьмя
|
||
фиксированными профилями. Для каждого профиля независимо удерживаются
|
||
последние декодированные JPEG-состояния BASE и ROI.
|
||
|
||
Отдельные JPEG-файлы не сохраняются: кодирование и декодирование
|
||
выполняются только в памяти средствами OpenCV.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
import cv2
|
||
import numpy as np
|
||
|
||
|
||
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
|
||
|
||
PREVIEW_DIRECTORY = Path("data/raw/lab027_previews")
|
||
MP4_PREVIEW_PATH = (
|
||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.mp4"
|
||
)
|
||
AVI_PREVIEW_PATH = (
|
||
PREVIEW_DIRECTORY / "lab027_roi_temporal_preview.avi"
|
||
)
|
||
|
||
OUTPUT_FPS = 30.0
|
||
PANEL_WIDTH = 640
|
||
PANEL_HEIGHT = 360
|
||
OUTPUT_WIDTH = PANEL_WIDTH * 2
|
||
OUTPUT_HEIGHT = PANEL_HEIGHT * 2
|
||
|
||
ROI_X_MIN = 0.20
|
||
ROI_X_MAX = 0.80
|
||
ROI_Y_MIN = 0.42
|
||
ROI_Y_MAX = 1.00
|
||
|
||
FRAME_TIME_EPSILON_SECONDS = 1e-9
|
||
NEW_UPDATE_LABEL_SECONDS = 0.15
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PreviewProfile:
|
||
"""
|
||
Описывает один из четырёх фиксированных preview-профилей.
|
||
"""
|
||
|
||
name: str
|
||
base_width: int
|
||
base_height: int
|
||
base_fps: float
|
||
base_quality: int
|
||
roi_enabled: bool
|
||
roi_width: int
|
||
roi_height: int
|
||
roi_fps: float
|
||
roi_quality: int
|
||
measured_payload_kbps: float
|
||
|
||
|
||
@dataclass
|
||
class ProfileState:
|
||
"""
|
||
Хранит независимое временное состояние BASE и ROI профиля.
|
||
"""
|
||
|
||
latest_base: np.ndarray | None = None
|
||
latest_roi: np.ndarray | None = None
|
||
next_base_time: float = 0.0
|
||
next_roi_time: float = 0.0
|
||
last_base_update_time: float = 0.0
|
||
last_roi_update_time: float = 0.0
|
||
base_update_count: int = 0
|
||
roi_update_count: int = 0
|
||
|
||
|
||
def read_video_metadata(
|
||
source_path: Path,
|
||
) -> tuple[int, int, float, int, float]:
|
||
"""
|
||
Читает и проверяет параметры исходного видео.
|
||
"""
|
||
|
||
if not source_path.exists():
|
||
raise RuntimeError(
|
||
f"Исходный видеофайл отсутствует: {source_path}"
|
||
)
|
||
|
||
capture = cv2.VideoCapture(str(source_path))
|
||
|
||
if not capture.isOpened():
|
||
raise RuntimeError(
|
||
f"OpenCV не смог открыть видео: {source_path}"
|
||
)
|
||
|
||
try:
|
||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||
fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||
finally:
|
||
capture.release()
|
||
|
||
if width <= 0 or height <= 0:
|
||
raise RuntimeError(
|
||
"OpenCV вернул некорректное разрешение видео."
|
||
)
|
||
|
||
if fps <= 0.0:
|
||
raise RuntimeError("FPS исходного видео равен нулю.")
|
||
|
||
if frame_count <= 0:
|
||
raise RuntimeError("Число кадров исходного видео равно нулю.")
|
||
|
||
duration_seconds = frame_count / fps
|
||
|
||
return width, height, fps, frame_count, duration_seconds
|
||
|
||
|
||
def build_profiles() -> list[PreviewProfile]:
|
||
"""
|
||
Создаёт ровно четыре согласованных preview-профиля.
|
||
"""
|
||
|
||
profiles = [
|
||
PreviewProfile(
|
||
name="baseline",
|
||
base_width=320,
|
||
base_height=180,
|
||
base_fps=2.0,
|
||
base_quality=40,
|
||
roi_enabled=False,
|
||
roi_width=0,
|
||
roi_height=0,
|
||
roi_fps=0.0,
|
||
roi_quality=0,
|
||
measured_payload_kbps=80.27,
|
||
),
|
||
PreviewProfile(
|
||
name="ROI normal",
|
||
base_width=240,
|
||
base_height=135,
|
||
base_fps=1.0,
|
||
base_quality=25,
|
||
roi_enabled=True,
|
||
roi_width=320,
|
||
roi_height=180,
|
||
roi_fps=2.0,
|
||
roi_quality=35,
|
||
measured_payload_kbps=93.28,
|
||
),
|
||
PreviewProfile(
|
||
name="ROI economy",
|
||
base_width=160,
|
||
base_height=90,
|
||
base_fps=1.0,
|
||
base_quality=25,
|
||
roi_enabled=True,
|
||
roi_width=320,
|
||
roi_height=180,
|
||
roi_fps=2.0,
|
||
roi_quality=35,
|
||
measured_payload_kbps=85.19,
|
||
),
|
||
PreviewProfile(
|
||
name="ROI degraded channel",
|
||
base_width=240,
|
||
base_height=135,
|
||
base_fps=1.0,
|
||
base_quality=20,
|
||
roi_enabled=True,
|
||
roi_width=320,
|
||
roi_height=180,
|
||
roi_fps=1.0,
|
||
roi_quality=30,
|
||
measured_payload_kbps=50.49,
|
||
),
|
||
]
|
||
|
||
if len(profiles) != 4:
|
||
raise RuntimeError(
|
||
"Preview 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_update(
|
||
current_time: float,
|
||
next_update_time: float,
|
||
epsilon_seconds: float = FRAME_TIME_EPSILON_SECONDS,
|
||
) -> bool:
|
||
"""
|
||
Проверяет наступление времени очередного обновления потока.
|
||
"""
|
||
|
||
return (
|
||
current_time + epsilon_seconds
|
||
>= next_update_time
|
||
)
|
||
|
||
|
||
def encode_decode_grayscale_jpeg(
|
||
gray_frame: np.ndarray,
|
||
jpeg_quality: int,
|
||
) -> np.ndarray:
|
||
"""
|
||
Кодирует grayscale JPEG в памяти и декодирует его обратно.
|
||
"""
|
||
|
||
if gray_frame.ndim != 2:
|
||
raise RuntimeError(
|
||
"JPEG preview должен получать grayscale-кадр."
|
||
)
|
||
|
||
encoding_ok, encoded = cv2.imencode(
|
||
".jpg",
|
||
gray_frame,
|
||
[cv2.IMWRITE_JPEG_QUALITY, jpeg_quality],
|
||
)
|
||
|
||
if not encoding_ok or encoded is None or encoded.size == 0:
|
||
raise RuntimeError("OpenCV не смог закодировать JPEG.")
|
||
|
||
decoded = cv2.imdecode(
|
||
encoded,
|
||
cv2.IMREAD_GRAYSCALE,
|
||
)
|
||
|
||
if decoded is None or decoded.shape != gray_frame.shape:
|
||
raise RuntimeError(
|
||
"Декодированный JPEG имеет некорректный размер."
|
||
)
|
||
|
||
return decoded
|
||
|
||
|
||
def encode_decode_base(
|
||
source_frame: np.ndarray,
|
||
profile: PreviewProfile,
|
||
) -> np.ndarray:
|
||
"""
|
||
Формирует очередное декодированное состояние BASE.
|
||
"""
|
||
|
||
source_gray = cv2.cvtColor(
|
||
source_frame,
|
||
cv2.COLOR_BGR2GRAY,
|
||
)
|
||
resized_base = cv2.resize(
|
||
source_gray,
|
||
(profile.base_width, profile.base_height),
|
||
interpolation=cv2.INTER_AREA,
|
||
)
|
||
|
||
return encode_decode_grayscale_jpeg(
|
||
resized_base,
|
||
profile.base_quality,
|
||
)
|
||
|
||
|
||
def encode_decode_roi(
|
||
source_frame: np.ndarray,
|
||
source_roi: tuple[int, int, int, int],
|
||
profile: PreviewProfile,
|
||
) -> np.ndarray:
|
||
"""
|
||
Вырезает ROI исходного BGR-кадра и формирует JPEG-состояние.
|
||
"""
|
||
|
||
if not profile.roi_enabled:
|
||
raise RuntimeError(
|
||
"Нельзя кодировать ROI для отключённого ROI-профиля."
|
||
)
|
||
|
||
x_min, y_min, x_max, y_max = source_roi
|
||
roi_bgr = source_frame[y_min:y_max, x_min:x_max]
|
||
|
||
if roi_bgr.size == 0:
|
||
raise RuntimeError("Вырезана пустая область ROI.")
|
||
|
||
roi_gray = cv2.cvtColor(
|
||
roi_bgr,
|
||
cv2.COLOR_BGR2GRAY,
|
||
)
|
||
resized_roi = cv2.resize(
|
||
roi_gray,
|
||
(profile.roi_width, profile.roi_height),
|
||
interpolation=cv2.INTER_AREA,
|
||
)
|
||
|
||
return encode_decode_grayscale_jpeg(
|
||
resized_roi,
|
||
profile.roi_quality,
|
||
)
|
||
|
||
|
||
def reconstruct_panel(
|
||
profile: PreviewProfile,
|
||
state: ProfileState,
|
||
panel_roi: tuple[int, int, int, int],
|
||
) -> np.ndarray:
|
||
"""
|
||
Восстанавливает одну grayscale-панель размером 640x360.
|
||
"""
|
||
|
||
if state.latest_base is None:
|
||
raise RuntimeError("BASE-состояние ещё не создано.")
|
||
|
||
reconstructed = cv2.resize(
|
||
state.latest_base,
|
||
(PANEL_WIDTH, PANEL_HEIGHT),
|
||
interpolation=cv2.INTER_LINEAR,
|
||
)
|
||
|
||
if profile.roi_enabled:
|
||
if state.latest_roi is None:
|
||
raise RuntimeError("ROI-состояние ещё не создано.")
|
||
|
||
x_min, y_min, x_max, y_max = panel_roi
|
||
resized_roi = cv2.resize(
|
||
state.latest_roi,
|
||
(x_max - x_min, y_max - y_min),
|
||
interpolation=cv2.INTER_LINEAR,
|
||
)
|
||
reconstructed[y_min:y_max, x_min:x_max] = resized_roi
|
||
|
||
return reconstructed
|
||
|
||
|
||
def draw_text_line(
|
||
image: np.ndarray,
|
||
text: str,
|
||
y_position: int,
|
||
text_color: tuple[int, int, int] = (255, 255, 255),
|
||
) -> None:
|
||
"""
|
||
Рисует одну ASCII-строку служебной информации OpenCV.
|
||
"""
|
||
|
||
cv2.putText(
|
||
image,
|
||
text,
|
||
(10, y_position),
|
||
cv2.FONT_HERSHEY_SIMPLEX,
|
||
0.46,
|
||
text_color,
|
||
1,
|
||
cv2.LINE_AA,
|
||
)
|
||
|
||
|
||
def draw_panel_information(
|
||
reconstructed_gray: np.ndarray,
|
||
profile: PreviewProfile,
|
||
state: ProfileState,
|
||
current_time: float,
|
||
panel_roi: tuple[int, int, int, int],
|
||
) -> np.ndarray:
|
||
"""
|
||
Добавляет рамку ROI, параметры и индикаторы обновления панели.
|
||
"""
|
||
|
||
panel = cv2.cvtColor(
|
||
reconstructed_gray,
|
||
cv2.COLOR_GRAY2BGR,
|
||
)
|
||
|
||
if profile.roi_enabled:
|
||
cv2.rectangle(
|
||
panel,
|
||
(panel_roi[0], panel_roi[1]),
|
||
(panel_roi[2] - 1, panel_roi[3] - 1),
|
||
(0, 255, 255),
|
||
2,
|
||
)
|
||
|
||
overlay = panel.copy()
|
||
cv2.rectangle(
|
||
overlay,
|
||
(0, 0),
|
||
(PANEL_WIDTH - 1, 142),
|
||
(0, 0, 0),
|
||
thickness=-1,
|
||
)
|
||
cv2.addWeighted(
|
||
overlay,
|
||
0.72,
|
||
panel,
|
||
0.28,
|
||
0.0,
|
||
panel,
|
||
)
|
||
|
||
base_age = max(
|
||
0.0,
|
||
current_time - state.last_base_update_time,
|
||
)
|
||
roi_age = max(
|
||
0.0,
|
||
current_time - state.last_roi_update_time,
|
||
)
|
||
base_is_new = (
|
||
base_age <= NEW_UPDATE_LABEL_SECONDS
|
||
)
|
||
roi_is_new = (
|
||
profile.roi_enabled
|
||
and roi_age <= NEW_UPDATE_LABEL_SECONDS
|
||
)
|
||
|
||
draw_text_line(panel, profile.name, 20)
|
||
draw_text_line(
|
||
panel,
|
||
(
|
||
f"BASE: {profile.base_width}x{profile.base_height}, "
|
||
f"{profile.base_fps:g} fps, Q{profile.base_quality}"
|
||
),
|
||
41,
|
||
)
|
||
|
||
if profile.roi_enabled:
|
||
roi_text = (
|
||
f"ROI: {profile.roi_width}x{profile.roi_height}, "
|
||
f"{profile.roi_fps:g} fps, Q{profile.roi_quality}"
|
||
)
|
||
roi_age_text = f"{roi_age:.3f} s"
|
||
else:
|
||
roi_text = "ROI: disabled"
|
||
roi_age_text = "disabled"
|
||
|
||
draw_text_line(panel, roi_text, 62)
|
||
draw_text_line(
|
||
panel,
|
||
(
|
||
f"payload={profile.measured_payload_kbps:.2f} kbps, "
|
||
f"source time={current_time:.3f} s"
|
||
),
|
||
83,
|
||
)
|
||
draw_text_line(
|
||
panel,
|
||
(
|
||
f"since BASE={base_age:.3f} s, "
|
||
f"since ROI={roi_age_text}"
|
||
),
|
||
104,
|
||
)
|
||
|
||
update_labels: list[str] = []
|
||
|
||
if base_is_new:
|
||
update_labels.append("NEW BASE")
|
||
|
||
if roi_is_new:
|
||
update_labels.append("NEW ROI")
|
||
|
||
if update_labels:
|
||
draw_text_line(
|
||
panel,
|
||
" | ".join(update_labels),
|
||
130,
|
||
text_color=(0, 255, 0),
|
||
)
|
||
|
||
return panel
|
||
|
||
|
||
def compose_grid(panels: list[np.ndarray]) -> np.ndarray:
|
||
"""
|
||
Объединяет четыре панели в сетку 2x2 размером 1280x720.
|
||
"""
|
||
|
||
if len(panels) != 4:
|
||
raise RuntimeError(
|
||
"Для сетки preview требуется ровно четыре панели."
|
||
)
|
||
|
||
for panel in panels:
|
||
if panel.shape != (PANEL_HEIGHT, PANEL_WIDTH, 3):
|
||
raise RuntimeError(
|
||
f"Некорректный размер панели: {panel.shape}"
|
||
)
|
||
|
||
top_row = np.hstack((panels[0], panels[1]))
|
||
bottom_row = np.hstack((panels[2], panels[3]))
|
||
grid = np.vstack((top_row, bottom_row))
|
||
|
||
if grid.shape != (OUTPUT_HEIGHT, OUTPUT_WIDTH, 3):
|
||
raise RuntimeError(
|
||
f"Некорректный размер сетки: {grid.shape}"
|
||
)
|
||
|
||
return grid
|
||
|
||
|
||
def open_preview_writer() -> tuple[cv2.VideoWriter, Path, bool]:
|
||
"""
|
||
Открывает MP4 writer либо разрешённый fallback AVI/MJPG.
|
||
"""
|
||
|
||
mp4_writer = cv2.VideoWriter(
|
||
str(MP4_PREVIEW_PATH),
|
||
cv2.VideoWriter_fourcc(*"mp4v"),
|
||
OUTPUT_FPS,
|
||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||
True,
|
||
)
|
||
|
||
if mp4_writer.isOpened():
|
||
return mp4_writer, MP4_PREVIEW_PATH, False
|
||
|
||
mp4_writer.release()
|
||
|
||
if MP4_PREVIEW_PATH.exists():
|
||
MP4_PREVIEW_PATH.unlink()
|
||
|
||
avi_writer = cv2.VideoWriter(
|
||
str(AVI_PREVIEW_PATH),
|
||
cv2.VideoWriter_fourcc(*"MJPG"),
|
||
OUTPUT_FPS,
|
||
(OUTPUT_WIDTH, OUTPUT_HEIGHT),
|
||
True,
|
||
)
|
||
|
||
if not avi_writer.isOpened():
|
||
avi_writer.release()
|
||
|
||
if AVI_PREVIEW_PATH.exists():
|
||
AVI_PREVIEW_PATH.unlink()
|
||
|
||
raise RuntimeError(
|
||
"OpenCV не смог открыть ни MP4, ни AVI writer."
|
||
)
|
||
|
||
return avi_writer, AVI_PREVIEW_PATH, True
|
||
|
||
|
||
def create_preview(
|
||
source_path: Path,
|
||
profiles: list[PreviewProfile],
|
||
source_width: int,
|
||
source_height: int,
|
||
source_fps: float,
|
||
expected_frame_count: int,
|
||
) -> tuple[Path, bool, int, list[ProfileState]]:
|
||
"""
|
||
Создаёт preview-видео с одним выходным кадром на исходный кадр.
|
||
"""
|
||
|
||
if len(profiles) != 4:
|
||
raise RuntimeError(
|
||
"Ожидалось ровно четыре preview-профиля."
|
||
)
|
||
|
||
PREVIEW_DIRECTORY.mkdir(
|
||
parents=True,
|
||
exist_ok=True,
|
||
)
|
||
|
||
capture = cv2.VideoCapture(str(source_path))
|
||
|
||
if not capture.isOpened():
|
||
raise RuntimeError(
|
||
f"OpenCV не смог открыть видео: {source_path}"
|
||
)
|
||
|
||
writer, preview_path, fallback_used = open_preview_writer()
|
||
source_roi = normalized_roi_to_pixels(
|
||
source_width,
|
||
source_height,
|
||
)
|
||
panel_roi = normalized_roi_to_pixels(
|
||
PANEL_WIDTH,
|
||
PANEL_HEIGHT,
|
||
)
|
||
states = [
|
||
ProfileState()
|
||
for _ in profiles
|
||
]
|
||
frame_index = 0
|
||
|
||
try:
|
||
while True:
|
||
frame_read, source_frame = capture.read()
|
||
|
||
if not frame_read:
|
||
break
|
||
|
||
if source_frame is None:
|
||
raise RuntimeError(
|
||
f"Получен пустой кадр {frame_index}."
|
||
)
|
||
|
||
if (
|
||
source_frame.shape[1] != source_width
|
||
or source_frame.shape[0] != source_height
|
||
):
|
||
raise RuntimeError(
|
||
"Размер кадра отличается от метаданных."
|
||
)
|
||
|
||
current_time = frame_index / source_fps
|
||
panels: list[np.ndarray] = []
|
||
|
||
for profile, state in zip(profiles, states):
|
||
if should_update(
|
||
current_time,
|
||
state.next_base_time,
|
||
):
|
||
state.latest_base = encode_decode_base(
|
||
source_frame,
|
||
profile,
|
||
)
|
||
state.last_base_update_time = current_time
|
||
state.next_base_time += 1.0 / profile.base_fps
|
||
state.base_update_count += 1
|
||
|
||
if (
|
||
profile.roi_enabled
|
||
and should_update(
|
||
current_time,
|
||
state.next_roi_time,
|
||
)
|
||
):
|
||
state.latest_roi = encode_decode_roi(
|
||
source_frame,
|
||
source_roi,
|
||
profile,
|
||
)
|
||
state.last_roi_update_time = current_time
|
||
state.next_roi_time += 1.0 / profile.roi_fps
|
||
state.roi_update_count += 1
|
||
|
||
reconstructed = reconstruct_panel(
|
||
profile,
|
||
state,
|
||
panel_roi,
|
||
)
|
||
panels.append(
|
||
draw_panel_information(
|
||
reconstructed,
|
||
profile,
|
||
state,
|
||
current_time,
|
||
panel_roi,
|
||
)
|
||
)
|
||
|
||
writer.write(compose_grid(panels))
|
||
frame_index += 1
|
||
|
||
if (
|
||
frame_index % 100 == 0
|
||
or frame_index == expected_frame_count
|
||
):
|
||
print(
|
||
f" Written frames: "
|
||
f"{frame_index}/{expected_frame_count}"
|
||
)
|
||
except Exception:
|
||
capture.release()
|
||
writer.release()
|
||
|
||
if preview_path.exists():
|
||
preview_path.unlink()
|
||
|
||
raise
|
||
finally:
|
||
capture.release()
|
||
writer.release()
|
||
|
||
if frame_index != expected_frame_count:
|
||
if preview_path.exists():
|
||
preview_path.unlink()
|
||
|
||
raise RuntimeError(
|
||
"Число записанных кадров не совпало с исходным: "
|
||
f"{frame_index} != {expected_frame_count}."
|
||
)
|
||
|
||
return (
|
||
preview_path,
|
||
fallback_used,
|
||
frame_index,
|
||
states,
|
||
)
|
||
|
||
|
||
def read_frame_at(
|
||
capture: cv2.VideoCapture,
|
||
frame_index: int,
|
||
) -> tuple[bool, tuple[int, ...] | None]:
|
||
"""
|
||
Читает один кадр preview по индексу для итоговой проверки.
|
||
"""
|
||
|
||
capture.set(
|
||
cv2.CAP_PROP_POS_FRAMES,
|
||
frame_index,
|
||
)
|
||
frame_read, frame = capture.read()
|
||
|
||
if not frame_read or frame is None:
|
||
return False, None
|
||
|
||
return True, frame.shape
|
||
|
||
|
||
def verify_preview(
|
||
preview_path: Path,
|
||
source_frame_count: int,
|
||
source_duration_seconds: float,
|
||
) -> tuple[
|
||
int,
|
||
int,
|
||
float,
|
||
int,
|
||
float,
|
||
tuple[bool, tuple[int, ...] | None],
|
||
tuple[bool, tuple[int, ...] | None],
|
||
tuple[bool, tuple[int, ...] | None],
|
||
]:
|
||
"""
|
||
Проверяет контейнер, геометрию, FPS, длительность и три кадра.
|
||
"""
|
||
|
||
if not preview_path.exists():
|
||
raise RuntimeError(
|
||
f"Preview отсутствует: {preview_path}"
|
||
)
|
||
|
||
if 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 != OUTPUT_WIDTH or height != OUTPUT_HEIGHT:
|
||
raise RuntimeError(
|
||
f"Некорректное разрешение preview: {width}x{height}."
|
||
)
|
||
|
||
if frame_count != source_frame_count:
|
||
raise RuntimeError(
|
||
"Число кадров preview не совпало с исходным."
|
||
)
|
||
|
||
allowed_duration_difference = (
|
||
1.0 / fps + FRAME_TIME_EPSILON_SECONDS
|
||
)
|
||
|
||
if (
|
||
abs(duration_seconds - source_duration_seconds)
|
||
> allowed_duration_difference
|
||
):
|
||
raise RuntimeError(
|
||
"Длительность preview отличается более чем на один кадр."
|
||
)
|
||
|
||
for label, frame_result in [
|
||
("первый", first_frame),
|
||
("средний", middle_frame),
|
||
("последний", last_frame),
|
||
]:
|
||
if not frame_result[0]:
|
||
raise RuntimeError(
|
||
f"Не удалось прочитать {label} кадр preview."
|
||
)
|
||
|
||
return (
|
||
width,
|
||
height,
|
||
fps,
|
||
frame_count,
|
||
duration_seconds,
|
||
first_frame,
|
||
middle_frame,
|
||
last_frame,
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
"""
|
||
Создаёт и проверяет динамическое preview Lab027.
|
||
"""
|
||
|
||
print("Reading source video metadata...")
|
||
(
|
||
source_width,
|
||
source_height,
|
||
source_fps,
|
||
source_frame_count,
|
||
source_duration_seconds,
|
||
) = read_video_metadata(SOURCE_VIDEO_PATH)
|
||
|
||
print(f" Source: {SOURCE_VIDEO_PATH}")
|
||
print(f" Resolution: {source_width}x{source_height}")
|
||
print(f" FPS: {source_fps:.6f}")
|
||
print(f" Frames: {source_frame_count}")
|
||
print(f" Duration: {source_duration_seconds:.6f} s")
|
||
|
||
profiles = build_profiles()
|
||
print("Creating dynamic preview...")
|
||
(
|
||
preview_path,
|
||
fallback_used,
|
||
written_frame_count,
|
||
states,
|
||
) = create_preview(
|
||
source_path=SOURCE_VIDEO_PATH,
|
||
profiles=profiles,
|
||
source_width=source_width,
|
||
source_height=source_height,
|
||
source_fps=source_fps,
|
||
expected_frame_count=source_frame_count,
|
||
)
|
||
|
||
print("Verifying preview...")
|
||
(
|
||
preview_width,
|
||
preview_height,
|
||
preview_fps,
|
||
preview_frame_count,
|
||
preview_duration_seconds,
|
||
first_frame,
|
||
middle_frame,
|
||
last_frame,
|
||
) = verify_preview(
|
||
preview_path,
|
||
source_frame_count,
|
||
source_duration_seconds,
|
||
)
|
||
|
||
print("")
|
||
print(f"Preview path: {preview_path}")
|
||
print(f"Fallback used: {fallback_used}")
|
||
print(f"File size: {preview_path.stat().st_size} bytes")
|
||
print(
|
||
f"Preview resolution: "
|
||
f"{preview_width}x{preview_height}"
|
||
)
|
||
print(f"Preview FPS: {preview_fps:.6f}")
|
||
print(f"Written frames: {written_frame_count}")
|
||
print(f"Verified frames: {preview_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("Profile update counts:")
|
||
|
||
for profile, state in zip(profiles, states):
|
||
print(
|
||
f" {profile.name}: "
|
||
f"BASE={state.base_update_count}, "
|
||
f"ROI={state.roi_update_count}"
|
||
)
|
||
|
||
print("")
|
||
print("Lab027 temporal preview completed successfully.")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|