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

870 lines
30 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.
"""
Lab028. Packetization of synchronous BASE + ROI JPEG video objects.
The laboratory forms the selected Lab027E profile directly from the source
video, keeps every JPEG and packet in memory, verifies the transport layer,
and writes only aggregate CSV/report/plot artifacts.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from pathlib import Path
import random
from typing import Callable
import cv2
import matplotlib
import numpy as np
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from protocol.video_packet import (
CompositeFrame,
CompositeReassembler,
HEADER_FORMAT,
HEADER_SIZE,
ObjectCRCError,
ObjectType,
PacketCRCError,
VideoPacket,
decode_packet,
encode_packet,
packetize_jpeg,
)
SOURCE_VIDEO_PATH = Path("data/raw/lab026_rover_source.mp4")
OUTPUT_DIRECTORY = Path("data/processed/lab028")
CSV_PATH = OUTPUT_DIRECTORY / "lab028_packet_payload_results.csv"
REPORT_PATH = OUTPUT_DIRECTORY / "lab028_report.txt"
OVERHEAD_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab028_overhead_efficiency.png"
)
TRAFFIC_PLOT_PATH = (
OUTPUT_DIRECTORY / "lab028_packets_wire_bitrate.png"
)
COMPOSITE_FPS = 3.0
BASE_WIDTH = 240
BASE_HEIGHT = 135
BASE_QUALITY = 23
ROI_WIDTH = 320
ROI_HEIGHT = 180
ROI_QUALITY = 33
ROI_X_MIN = 0.20
ROI_X_MAX = 0.80
ROI_Y_MIN = 0.42
ROI_Y_MAX = 1.00
PAYLOAD_LENGTHS = (64, 128, 256, 512, 1024)
FRAME_TIME_EPSILON_SECONDS = 1e-9
CSV_FIELDS = [
"max_payload_bytes",
"composite_frames",
"mean_base_packets_per_frame",
"mean_roi_packets_per_frame",
"mean_total_packets_per_frame",
"max_total_packets_per_frame",
"packets_per_second",
"jpeg_payload_bytes",
"jpeg_payload_bitrate_kbps",
"header_bytes_per_second",
"header_bitrate_kbps",
"wire_bytes",
"wire_bitrate_kbps",
"service_data_percent",
"efficiency_percent",
"mean_wire_packet_bytes",
"p95_wire_packet_bytes",
"max_wire_packet_bytes",
]
@dataclass(frozen=True)
class VideoMetadata:
width: int
height: int
fps: float
frame_count: int
duration_seconds: float
@dataclass(frozen=True)
class EncodedComposite:
composite_frame_id: int
source_frame_index: int
base_jpeg: bytes
roi_jpeg: bytes
@dataclass(frozen=True)
class PayloadMetrics:
max_payload_bytes: int
composite_frames: int
mean_base_packets_per_frame: float
mean_roi_packets_per_frame: float
mean_total_packets_per_frame: float
max_total_packets_per_frame: int
packets_per_second: float
jpeg_payload_bytes: int
jpeg_payload_bitrate_kbps: float
header_bytes_per_second: float
header_bitrate_kbps: float
wire_bytes: int
wire_bitrate_kbps: float
service_data_percent: float
efficiency_percent: float
mean_wire_packet_bytes: float
p95_wire_packet_bytes: float
max_wire_packet_bytes: int
@dataclass(frozen=True)
class TestResult:
name: str
passed: bool
detail: str
def normalized_roi_to_pixels(
width: int,
height: int,
) -> tuple[int, int, int, int]:
"""Use the normalized ROI coordinates from Lab027 through Lab027E."""
coordinates = (
int(round(width * ROI_X_MIN)),
int(round(height * ROI_Y_MIN)),
int(round(width * ROI_X_MAX)),
int(round(height * ROI_Y_MAX)),
)
x_min, y_min, x_max, y_max = coordinates
if not (
0 <= x_min < x_max <= width
and 0 <= y_min < y_max <= height
):
raise RuntimeError("calculated ROI is outside the source frame")
return coordinates
def encode_grayscale_jpeg(image: np.ndarray, quality: int) -> bytes:
"""Encode one grayscale image to an in-memory JPEG."""
encoded, buffer = cv2.imencode(
".jpg",
image,
[int(cv2.IMWRITE_JPEG_QUALITY), int(quality)],
)
if not encoded:
raise RuntimeError("OpenCV could not encode JPEG")
jpeg = buffer.tobytes()
if not jpeg:
raise RuntimeError("OpenCV produced an empty JPEG")
return jpeg
def encode_composite(
source_frame: np.ndarray,
source_roi: tuple[int, int, int, int],
composite_frame_id: int,
source_frame_index: int,
) -> EncodedComposite:
"""Form synchronous BASE and ROI JPEGs from exactly one source frame."""
grayscale = cv2.cvtColor(source_frame, cv2.COLOR_BGR2GRAY)
base = cv2.resize(
grayscale,
(BASE_WIDTH, BASE_HEIGHT),
interpolation=cv2.INTER_AREA,
)
x_min, y_min, x_max, y_max = source_roi
roi = grayscale[y_min:y_max, x_min:x_max]
if roi.size == 0:
raise RuntimeError("source ROI is empty")
roi = cv2.resize(
roi,
(ROI_WIDTH, ROI_HEIGHT),
interpolation=cv2.INTER_AREA,
)
return EncodedComposite(
composite_frame_id=composite_frame_id,
source_frame_index=source_frame_index,
base_jpeg=encode_grayscale_jpeg(base, BASE_QUALITY),
roi_jpeg=encode_grayscale_jpeg(roi, ROI_QUALITY),
)
def load_video_profile(
source_path: Path,
) -> tuple[VideoMetadata, list[EncodedComposite]]:
"""Read the video sequentially and select synchronous updates at 3 fps."""
if not source_path.exists():
raise FileNotFoundError(f"source video is missing: {source_path}")
capture = cv2.VideoCapture(str(source_path))
if not capture.isOpened():
raise RuntimeError(f"OpenCV could not open {source_path}")
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))
declared_frame_count = int(
capture.get(cv2.CAP_PROP_FRAME_COUNT)
)
if width <= 0 or height <= 0 or fps <= 0.0:
capture.release()
raise RuntimeError("invalid source video metadata")
source_roi = normalized_roi_to_pixels(width, height)
selected: list[EncodedComposite] = []
source_frame_index = 0
next_composite_time = 0.0
try:
while True:
frame_read, source_frame = capture.read()
if not frame_read or source_frame is None:
break
source_time = source_frame_index / fps
if (
source_time + FRAME_TIME_EPSILON_SECONDS
>= next_composite_time
):
selected.append(
encode_composite(
source_frame,
source_roi,
len(selected),
source_frame_index,
)
)
next_composite_time += 1.0 / COMPOSITE_FPS
source_frame_index += 1
finally:
capture.release()
if source_frame_index <= 0 or not selected:
raise RuntimeError("source video did not yield frames")
if (
declared_frame_count > 0
and source_frame_index != declared_frame_count
):
raise RuntimeError(
"decoded frame count differs from video metadata: "
f"{source_frame_index} != {declared_frame_count}"
)
metadata = VideoMetadata(
width=width,
height=height,
fps=fps,
frame_count=source_frame_index,
duration_seconds=source_frame_index / fps,
)
return metadata, selected
def packets_for_composite(
composite: EncodedComposite,
max_payload_bytes: int,
) -> tuple[list[bytes], list[bytes]]:
"""Packetize BASE and ROI separately with one composite frame ID."""
base_packets = packetize_jpeg(
composite.base_jpeg,
composite.composite_frame_id,
ObjectType.BASE,
max_payload_bytes,
)
roi_packets = packetize_jpeg(
composite.roi_jpeg,
composite.composite_frame_id,
ObjectType.ROI,
max_payload_bytes,
)
return base_packets, roi_packets
def calculate_payload_metrics(
composites: list[EncodedComposite],
duration_seconds: float,
max_payload_bytes: int,
) -> PayloadMetrics:
"""Calculate actual packet and bitrate statistics for one payload limit."""
base_counts: list[int] = []
roi_counts: list[int] = []
total_counts: list[int] = []
wire_packet_sizes: list[int] = []
for composite in composites:
base_packets, roi_packets = packets_for_composite(
composite, max_payload_bytes
)
base_counts.append(len(base_packets))
roi_counts.append(len(roi_packets))
total_counts.append(len(base_packets) + len(roi_packets))
wire_packet_sizes.extend(
len(packet) for packet in base_packets + roi_packets
)
jpeg_payload_bytes = sum(
len(composite.base_jpeg) + len(composite.roi_jpeg)
for composite in composites
)
packet_count = len(wire_packet_sizes)
header_bytes = packet_count * HEADER_SIZE
wire_bytes = jpeg_payload_bytes + header_bytes
return PayloadMetrics(
max_payload_bytes=max_payload_bytes,
composite_frames=len(composites),
mean_base_packets_per_frame=float(np.mean(base_counts)),
mean_roi_packets_per_frame=float(np.mean(roi_counts)),
mean_total_packets_per_frame=float(np.mean(total_counts)),
max_total_packets_per_frame=max(total_counts),
packets_per_second=packet_count / duration_seconds,
jpeg_payload_bytes=jpeg_payload_bytes,
jpeg_payload_bitrate_kbps=(
jpeg_payload_bytes * 8.0 / duration_seconds / 1000.0
),
header_bytes_per_second=header_bytes / duration_seconds,
header_bitrate_kbps=(
header_bytes * 8.0 / duration_seconds / 1000.0
),
wire_bytes=wire_bytes,
wire_bitrate_kbps=(
wire_bytes * 8.0 / duration_seconds / 1000.0
),
service_data_percent=header_bytes / wire_bytes * 100.0,
efficiency_percent=jpeg_payload_bytes / wire_bytes * 100.0,
mean_wire_packet_bytes=float(np.mean(wire_packet_sizes)),
p95_wire_packet_bytes=float(
np.percentile(wire_packet_sizes, 95)
),
max_wire_packet_bytes=max(wire_packet_sizes),
)
def feed_packets(
packets: list[bytes],
reassembler: CompositeReassembler | None = None,
) -> tuple[list[CompositeFrame], CompositeReassembler]:
"""Feed packets and collect every atomically published frame."""
receiver = reassembler or CompositeReassembler()
completed = []
for packet in packets:
frame = receiver.ingest(packet)
if frame is not None:
completed.append(frame)
return completed, receiver
def assert_frame_matches(
frame: CompositeFrame,
expected: EncodedComposite,
) -> None:
if frame.composite_frame_id != expected.composite_frame_id:
raise AssertionError("composite frame ID differs")
if frame.base_jpeg != expected.base_jpeg:
raise AssertionError("BASE JPEG differs byte-for-byte")
if frame.roi_jpeg != expected.roi_jpeg:
raise AssertionError("ROI JPEG differs byte-for-byte")
def run_functional_tests(
composites: list[EncodedComposite],
) -> list[TestResult]:
"""Run header and all mandatory Lab028 transport checks."""
if len(composites) < 2:
raise RuntimeError("functional checks need two video frames")
first = composites[0]
second = composites[1]
base_packets, roi_packets = packets_for_composite(first, 256)
all_packets = base_packets + roi_packets
tests: list[tuple[str, Callable[[], str]]] = []
def header_round_trip() -> str:
parsed = decode_packet(all_packets[0])
rebuilt = encode_packet(
VideoPacket(
composite_frame_id=parsed.composite_frame_id,
object_type=parsed.object_type,
fragment_index=parsed.fragment_index,
fragment_count=parsed.fragment_count,
jpeg_size=parsed.jpeg_size,
object_crc32=parsed.object_crc32,
payload=parsed.payload,
)
)
if rebuilt != all_packets[0]:
raise AssertionError("serialized bytes changed after round trip")
return f"fixed {HEADER_SIZE}-byte header round trip is exact"
def ordered_lossless() -> str:
frames, _ = feed_packets(all_packets)
if len(frames) != 1:
raise AssertionError("ordered transfer did not emit one frame")
assert_frame_matches(frames[0], first)
return "BASE and ROI match original JPEG bytes"
def shuffled_packets() -> str:
shuffled = list(all_packets)
random.Random(28001).shuffle(shuffled)
frames, _ = feed_packets(shuffled)
if len(frames) != 1:
raise AssertionError("shuffled transfer did not emit one frame")
assert_frame_matches(frames[0], first)
return "arbitrary packet order reconstructed correctly"
def duplicate_packets() -> str:
duplicated = list(all_packets)
duplicated.extend(
[all_packets[0], all_packets[len(base_packets)]]
)
random.Random(28002).shuffle(duplicated)
frames, receiver = feed_packets(duplicated)
if len(frames) != 1:
raise AssertionError("duplicates changed publication count")
assert_frame_matches(frames[0], first)
if receiver.duplicate_packets < 2:
raise AssertionError("exact duplicates were not counted")
return f"{receiver.duplicate_packets} exact duplicates ignored"
def packet_crc_corruption() -> str:
corrupted = bytearray(all_packets[0])
corrupted[-1] ^= 0x01
receiver = CompositeReassembler()
try:
receiver.ingest(bytes(corrupted))
except PacketCRCError:
pass
else:
raise AssertionError("corrupted packet passed packet CRC")
frames, _ = feed_packets(all_packets[1:], receiver)
if frames:
raise AssertionError("frame emitted despite rejected packet")
return "payload bit flip rejected; composite not emitted"
def missing_fragment() -> str:
missing_packet = roi_packets[len(roi_packets) // 2]
missing_index = decode_packet(missing_packet).fragment_index
remaining = [
packet
for packet in all_packets
if packet is not missing_packet
]
frames, receiver = feed_packets(remaining)
if frames:
raise AssertionError("frame emitted with a missing fragment")
missing = receiver.missing_fragments(
first.composite_frame_id, ObjectType.ROI
)
if missing is None or missing_index not in missing:
raise AssertionError("missing fragment was not reported")
return f"ROI fragment {missing_index} reported missing"
def object_crc_corruption() -> str:
target_position = len(base_packets)
parsed = decode_packet(all_packets[target_position])
changed_payload = bytearray(parsed.payload)
changed_payload[0] ^= 0x01
altered = encode_packet(
VideoPacket(
composite_frame_id=parsed.composite_frame_id,
object_type=parsed.object_type,
fragment_index=parsed.fragment_index,
fragment_count=parsed.fragment_count,
jpeg_size=parsed.jpeg_size,
object_crc32=parsed.object_crc32,
payload=bytes(changed_payload),
)
)
formally_valid = list(all_packets)
formally_valid[target_position] = altered
receiver = CompositeReassembler()
emitted = []
object_error_seen = False
for packet in formally_valid:
try:
frame = receiver.ingest(packet)
except ObjectCRCError:
object_error_seen = True
continue
if frame is not None:
emitted.append(frame)
if not object_error_seen:
raise AssertionError("object CRC did not detect changed JPEG")
if emitted:
raise AssertionError("frame emitted after object CRC failure")
return "valid packet CRCs still failed full-object CRC"
def adjacent_frames_do_not_mix() -> str:
first_packets = sum(packets_for_composite(first, 256), [])
second_packets = sum(packets_for_composite(second, 256), [])
interleaved = first_packets + second_packets
random.Random(28003).shuffle(interleaved)
frames, _ = feed_packets(interleaved)
by_id = {frame.composite_frame_id: frame for frame in frames}
if set(by_id) != {
first.composite_frame_id,
second.composite_frame_id,
}:
raise AssertionError("adjacent frames were lost or mixed")
assert_frame_matches(by_id[first.composite_frame_id], first)
assert_frame_matches(by_id[second.composite_frame_id], second)
return "two interleaved frame IDs remained independent"
def base_only_is_not_atomic() -> str:
frames, receiver = feed_packets(base_packets)
if frames:
raise AssertionError("BASE-only input emitted a composite")
if not receiver.object_is_complete(
first.composite_frame_id, ObjectType.BASE
):
raise AssertionError("complete BASE object was not retained")
return "complete BASE retained while composite stayed unpublished"
tests.extend(
[
("header_serialization_round_trip", header_round_trip),
("ordered_lossless_transfer", ordered_lossless),
("random_packet_order", shuffled_packets),
("exact_duplicate_packets", duplicate_packets),
("packet_crc_corruption", packet_crc_corruption),
("missing_fragment", missing_fragment),
("object_crc_corruption", object_crc_corruption),
("adjacent_frame_isolation", adjacent_frames_do_not_mix),
("base_without_roi_atomicity", base_only_is_not_atomic),
]
)
results = []
for name, test in tests:
try:
detail = test()
except Exception as error:
results.append(TestResult(name, False, str(error)))
else:
results.append(TestResult(name, True, detail))
failed = [result for result in results if not result.passed]
if failed:
details = "; ".join(
f"{result.name}: {result.detail}" for result in failed
)
raise RuntimeError(f"functional transport tests failed: {details}")
return results
def save_csv(metrics: list[PayloadMetrics]) -> None:
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
with CSV_PATH.open("w", encoding="utf-8", newline="") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=CSV_FIELDS)
writer.writeheader()
for item in metrics:
row = {}
for field_name in CSV_FIELDS:
value = getattr(item, field_name)
row[field_name] = (
f"{value:.6f}"
if isinstance(value, float)
else value
)
writer.writerow(row)
def save_plots(metrics: list[PayloadMetrics]) -> None:
payloads = [item.max_payload_bytes for item in metrics]
figure, axis = plt.subplots(figsize=(9, 5.5))
axis.plot(
payloads,
[item.service_data_percent for item in metrics],
marker="o",
linewidth=2,
label="Service data (header / wire)",
)
axis.plot(
payloads,
[item.efficiency_percent for item in metrics],
marker="s",
linewidth=2,
label="Efficiency (JPEG / wire)",
)
axis.set_xscale("log", base=2)
axis.set_xticks(payloads)
axis.set_xticklabels([str(value) for value in payloads])
axis.set_xlabel("Maximum packet payload, bytes")
axis.set_ylabel("Share, %")
axis.set_title("Lab028 packet overhead and efficiency")
axis.grid(True, alpha=0.3)
axis.legend()
figure.tight_layout()
figure.savefig(OVERHEAD_PLOT_PATH, dpi=160)
plt.close(figure)
figure, packet_axis = plt.subplots(figsize=(9, 5.5))
bitrate_axis = packet_axis.twinx()
packet_line = packet_axis.plot(
payloads,
[item.packets_per_second for item in metrics],
color="tab:blue",
marker="o",
linewidth=2,
label="Packets/s",
)
bitrate_lines = bitrate_axis.plot(
payloads,
[item.wire_bitrate_kbps for item in metrics],
color="tab:red",
marker="s",
linewidth=2,
label="Wire bitrate",
)
bitrate_axis.plot(
payloads,
[item.jpeg_payload_bitrate_kbps for item in metrics],
color="tab:green",
linestyle="--",
linewidth=2,
label="JPEG payload bitrate",
)
packet_axis.set_xscale("log", base=2)
packet_axis.set_xticks(payloads)
packet_axis.set_xticklabels([str(value) for value in payloads])
packet_axis.set_xlabel("Maximum packet payload, bytes")
packet_axis.set_ylabel("Packets per second", color="tab:blue")
bitrate_axis.set_ylabel("Bitrate, kbit/s", color="tab:red")
packet_axis.set_title("Lab028 packet rate and wire bitrate")
packet_axis.grid(True, alpha=0.3)
lines = packet_line + bitrate_lines + bitrate_axis.lines[1:]
packet_axis.legend(
lines,
[line.get_label() for line in lines],
loc="best",
)
figure.tight_layout()
figure.savefig(TRAFFIC_PLOT_PATH, dpi=160)
plt.close(figure)
def metrics_table(metrics: list[PayloadMetrics]) -> list[str]:
lines = [
(
"payload | BASE pkt/frame | ROI pkt/frame | all pkt/frame | "
"max pkt/frame | pkt/s | JPEG kbit/s | headers B/s | "
"wire kbit/s | service % | efficiency % | packet mean/p95/max"
),
(
"-------:|---------------:|--------------:|--------------:|"
"--------------:|------:|------------:|------------:|"
"------------:|----------:|-------------:|--------------------:"
),
]
for item in metrics:
lines.append(
f"{item.max_payload_bytes} | "
f"{item.mean_base_packets_per_frame:.3f} | "
f"{item.mean_roi_packets_per_frame:.3f} | "
f"{item.mean_total_packets_per_frame:.3f} | "
f"{item.max_total_packets_per_frame} | "
f"{item.packets_per_second:.3f} | "
f"{item.jpeg_payload_bitrate_kbps:.3f} | "
f"{item.header_bytes_per_second:.3f} | "
f"{item.wire_bitrate_kbps:.3f} | "
f"{item.service_data_percent:.3f} | "
f"{item.efficiency_percent:.3f} | "
f"{item.mean_wire_packet_bytes:.1f}/"
f"{item.p95_wire_packet_bytes:.1f}/"
f"{item.max_wire_packet_bytes}"
)
return lines
def write_report(
metadata: VideoMetadata,
composites: list[EncodedComposite],
metrics: list[PayloadMetrics],
test_results: list[TestResult],
) -> None:
source_roi = normalized_roi_to_pixels(
metadata.width, metadata.height
)
base_sizes = [len(item.base_jpeg) for item in composites]
roi_sizes = [len(item.roi_jpeg) for item in composites]
lines = [
"Lab028. Пакетирование синхронного BASE + ROI",
"",
"Профиль и исходные данные",
f"- Видео: {SOURCE_VIDEO_PATH}",
(
f"- Источник: {metadata.width}x{metadata.height}, "
f"{metadata.fps:.6f} fps, {metadata.frame_count} кадров, "
f"{metadata.duration_seconds:.6f} с."
),
(
f"- ROI: x={ROI_X_MIN:.2f}...{ROI_X_MAX:.2f}, "
f"y={ROI_Y_MIN:.2f}...{ROI_Y_MAX:.2f}; "
f"пиксели x={source_roi[0]}...{source_roi[2]}, "
f"y={source_roi[1]}...{source_roi[3]}."
),
(
f"- Синхронные составные кадры: {len(composites)} "
f"при {COMPOSITE_FPS:.3f} fps."
),
(
f"- BASE: {BASE_WIDTH}x{BASE_HEIGHT}, grayscale JPEG Q"
f"{BASE_QUALITY}; средний размер {np.mean(base_sizes):.3f} B, "
f"min/max {min(base_sizes)}/{max(base_sizes)} B."
),
(
f"- ROI: {ROI_WIDTH}x{ROI_HEIGHT}, grayscale JPEG Q"
f"{ROI_QUALITY}; средний размер {np.mean(roi_sizes):.3f} B, "
f"min/max {min(roi_sizes)}/{max(roi_sizes)} B."
),
(
"- BASE и ROI формируются из одного source frame и имеют "
"общий composite_frame_id."
),
"",
"Бинарный заголовок",
f"- struct format: {HEADER_FORMAT}",
f"- Размер: {HEADER_SIZE} байта; network byte order; padding нет.",
(
"- Layout: magic[4] @0, version:u8 @4, object_type:u8 @5, "
"header_size:u16 @6, composite_frame_id:u32 @8, "
"fragment_index:u16 @12, fragment_count:u16 @14, "
"payload_length:u16 @16, flags:u16 @18, jpeg_size:u32 @20, "
"object_crc32:u32 @24, packet_crc32:u32 @28."
),
"- object_type: 1=BASE, 2=ROI; flags зарезервирован и равен 0.",
"",
"CRC32",
(
"- object_crc32 = zlib.crc32(полный JPEG) & 0xFFFFFFFF; "
"значение помещается во все фрагменты объекта и проверяется "
"после полной сборки."
),
(
"- packet_crc32: сначала поле packet_crc32 заголовка "
"обнуляется, затем CRC считается как "
"zlib.crc32(header_with_zero_crc + payload) & 0xFFFFFFFF."
),
"- Таким образом packet CRC защищает все поля заголовка и payload.",
"",
"Функциональные проверки",
]
lines.extend(
f"- {'PASS' if item.passed else 'FAIL'} {item.name}: {item.detail}"
for item in test_results
)
lines.extend(
[
"",
"Результаты размеров packet payload",
*metrics_table(metrics),
"",
(
"JPEG payload bitrate одинаков для всех строк, потому что "
"исходные JPEG не меняются при выборе размера фрагмента."
),
(
"Wire bitrate включает только JPEG payload и 32-байтные "
"заголовки каждого пакета."
),
(
"Wire bitrate пока НЕ включает FEC, преамбулу, "
"синхронизацию, модуляцию, интервалы, повторные передачи, "
"команды управления и телеметрию."
),
(
"Лучший размер packet payload автоматически не выбирается: "
"таблица показывает только транспортный компромисс."
),
"",
"Артефакты",
f"- CSV: {CSV_PATH}",
f"- Overhead/efficiency: {OVERHEAD_PLOT_PATH}",
f"- Packet count/wire bitrate: {TRAFFIC_PLOT_PATH}",
(
"- Промежуточные JPEG и пакеты сохранялись только в памяти; "
"бинарные дампы не создавались."
),
"",
]
)
REPORT_PATH.write_text("\n".join(lines), encoding="utf-8")
def validate_outputs(metrics: list[PayloadMetrics]) -> None:
if len(metrics) != len(PAYLOAD_LENGTHS):
raise RuntimeError("not all payload sizes were measured")
if [item.max_payload_bytes for item in metrics] != list(
PAYLOAD_LENGTHS
):
raise RuntimeError("payload result order differs")
if any(
abs(
item.service_data_percent
+ item.efficiency_percent
- 100.0
) > 1e-9
for item in metrics
):
raise RuntimeError("overhead and efficiency do not sum to 100%")
for path in (
CSV_PATH,
REPORT_PATH,
OVERHEAD_PLOT_PATH,
TRAFFIC_PLOT_PATH,
):
if not path.exists() or path.stat().st_size <= 0:
raise RuntimeError(f"missing or empty output: {path}")
def main() -> None:
print("Lab028: loading and JPEG-encoding the Lab027E profile...")
metadata, composites = load_video_profile(SOURCE_VIDEO_PATH)
print(
f" source={metadata.width}x{metadata.height}, "
f"{metadata.fps:.3f} fps, frames={metadata.frame_count}"
)
print(f" synchronous composite frames={len(composites)}")
print("Running functional transport checks...")
test_results = run_functional_tests(composites)
for result in test_results:
print(f" PASS {result.name}: {result.detail}")
print("Measuring packet payload sizes...")
metrics = [
calculate_payload_metrics(
composites,
metadata.duration_seconds,
payload_length,
)
for payload_length in PAYLOAD_LENGTHS
]
for item in metrics:
print(
f" payload={item.max_payload_bytes:4d} B: "
f"{item.packets_per_second:.3f} packet/s, "
f"wire={item.wire_bitrate_kbps:.3f} kbit/s, "
f"service={item.service_data_percent:.3f}%"
)
OUTPUT_DIRECTORY.mkdir(parents=True, exist_ok=True)
save_csv(metrics)
save_plots(metrics)
write_report(metadata, composites, metrics, test_results)
validate_outputs(metrics)
print(f"CSV: {CSV_PATH}")
print(f"Report: {REPORT_PATH}")
print(f"Plots: {OVERHEAD_PLOT_PATH}, {TRAFFIC_PLOT_PATH}")
print("Lab028 completed successfully.")
if __name__ == "__main__":
main()