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>
This commit is contained in:
869
experiments/lab028_video_packetization.py
Normal file
869
experiments/lab028_video_packetization.py
Normal file
@@ -0,0 +1,869 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user