321 lines
17 KiB
Python
321 lines
17 KiB
Python
"""Lab051: измеренный блочный RX и возраст JPEG в шестисекундном потоке.
|
||
|
||
Источник синтетический, из реального фото. RF не выполняется. Виртуальное
|
||
время работника использует фактическое время каждого push/сборки/JPEG decode.
|
||
Это офлайн-воспроизведение; отставание явно означает требуемый внешний IQ backlog.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from collections import Counter
|
||
from dataclasses import asdict
|
||
import hashlib
|
||
from io import BytesIO
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
import statistics
|
||
import sys
|
||
import time
|
||
|
||
if __package__ in (None, ""):
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
from experiments import lab050_video_stream as video
|
||
from experiments import lab051_streaming_receiver as fast
|
||
from experiments.lab051_receiver_profile import OUTPUT, ROOT, StageProfiler, array_observation, process_memory
|
||
from protocol.video_frame_scheduler import FramePolicy, schedule_video_frames
|
||
|
||
|
||
def write_result(name: str, result: dict) -> None:
|
||
OUTPUT.mkdir(parents=True, exist_ok=True)
|
||
(OUTPUT / name).write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
|
||
class ImageSink:
|
||
"""Только текущий объект; никаких ожидаемых ID, размеров JPEG или TX bytes."""
|
||
|
||
def __init__(self):
|
||
self.object_id = -1
|
||
self.last_completed_id = -1
|
||
self.assembler = None
|
||
self.first_rf_sample = None
|
||
self.first_rx_time = None
|
||
self.retained_payload_peak = 0
|
||
self.assembly_seconds = self.jpeg_decode_seconds = 0.0
|
||
self.rejected = Counter()
|
||
|
||
def offer(self, packet, received_time: float) -> dict | None:
|
||
started = time.perf_counter()
|
||
fragment = video.transport.recover_fragment_from_payload(packet.payload)
|
||
if fragment is None or fragment.original_size > 65536 or fragment.fragment_count > 269:
|
||
self.rejected["invalid_fragment"] += 1
|
||
self.assembly_seconds += time.perf_counter() - started
|
||
return None
|
||
if fragment.image_id < self.object_id or fragment.image_id <= self.last_completed_id:
|
||
self.rejected["older_object"] += 1
|
||
self.assembly_seconds += time.perf_counter() - started
|
||
return None
|
||
if fragment.image_id > self.object_id:
|
||
self.object_id = fragment.image_id
|
||
self.assembler = video.transport.ObjectAssembler()
|
||
self.first_rf_sample = packet.start_sample
|
||
self.first_rx_time = received_time
|
||
self.assembler.offer(fragment)
|
||
self.retained_payload_peak = max(self.retained_payload_peak, fragment.original_size)
|
||
jpeg = self.assembler.recovered_object()
|
||
self.assembly_seconds += time.perf_counter() - started
|
||
if jpeg is None:
|
||
return None
|
||
decode_started = time.perf_counter()
|
||
try:
|
||
with Image.open(BytesIO(jpeg)) as image:
|
||
image.load()
|
||
dimensions = image.size
|
||
except (OSError, ValueError):
|
||
self.rejected["jpeg_decode"] += 1
|
||
return None
|
||
finally:
|
||
self.jpeg_decode_seconds += time.perf_counter() - decode_started
|
||
self.last_completed_id = self.object_id
|
||
self.assembler = None
|
||
return {"image_id": self.object_id, "jpeg": jpeg, "dimensions": dimensions,
|
||
"first_rf_sample": self.first_rf_sample, "last_rf_sample": packet.end_sample,
|
||
"first_rx_time": self.first_rx_time}
|
||
|
||
|
||
def run_stream(sequence: list[video.EncodedImage]) -> dict:
|
||
"""Вход RX — только последовательные IQ-блоки; TX-диагностика после JPEG."""
|
||
mode = video.phy.PhyMode(500000)
|
||
receiver = fast.StreamingReceiver()
|
||
sink = ImageSink()
|
||
random_generator = np.random.default_rng(50050)
|
||
frames = video.make_frame_groups(sequence, 5)
|
||
packet_time = video.FRAME_INTERVALS / mode.symbol_rate
|
||
bitrate = (256 + video.HEADER_SIZE) * 8 / packet_time
|
||
# TX использует известную нижнюю задержку радио/буфера, без предсказания
|
||
# будущего CPU и без знания RX verdict. Перегрузка RX измеряется ниже.
|
||
buffer_delay = (receiver.lookahead + 2*receiver.core_samples) / mode.sample_rate_hz
|
||
schedule = schedule_video_frames(frames, (), FramePolicy.LATEST_ONLY, bitrate,
|
||
max_video_age_seconds=.2, completion_latency_seconds=buffer_delay)
|
||
starts = {}
|
||
for sent in schedule.transmitted:
|
||
starts.setdefault(sent.item.composite_frame_id, sent.start_seconds)
|
||
horizon = len(sequence) / 5
|
||
encoded_lookup = {image.image_id: image for image in sequence}
|
||
capture_lookup = {frame.composite_frame_id: frame.generation_time_seconds for frame in frames}
|
||
pending = np.empty(0, np.complex64)
|
||
total_samples = tx_packets = 0
|
||
worker_time = rx_processing = max_lag = 0.0
|
||
completed = []
|
||
block_metrics = []
|
||
rss_samples = []
|
||
|
||
def consume_block(block, *, final=False):
|
||
nonlocal total_samples, worker_time, rx_processing, max_lag
|
||
total_samples += block.size
|
||
arrival = total_samples / mode.sample_rate_hz
|
||
worker_start = max(worker_time, arrival)
|
||
started = time.perf_counter()
|
||
packets = receiver.finish() if final else receiver.push_iq_block(block)
|
||
cpu = time.perf_counter() - started
|
||
rx_processing += cpu
|
||
worker_time = worker_start + cpu
|
||
for packet in packets:
|
||
started = time.perf_counter()
|
||
item = sink.offer(packet, worker_time)
|
||
cost = time.perf_counter() - started
|
||
worker_time += cost
|
||
if item is not None:
|
||
# Приём/сборка/JPEG уже завершены. TX используется только теперь.
|
||
jpeg = item.pop("jpeg")
|
||
image_id = item["image_id"]
|
||
capture = capture_lookup.get(image_id)
|
||
age = None if capture is None else worker_time - capture
|
||
completed.append({**item, "decode_complete_timestamp": worker_time,
|
||
"capture_timestamp": capture, "image_age_seconds": age,
|
||
"fresh": age is not None and 0 <= age <= .2,
|
||
"sha256": hashlib.sha256(jpeg).hexdigest(),
|
||
"sha_match": image_id in encoded_lookup and jpeg == encoded_lookup[image_id].jpeg})
|
||
lag = max(0., worker_time - arrival)
|
||
max_lag = max(max_lag, lag)
|
||
block_metrics.append({"samples_received": total_samples, "arrival_time": arrival,
|
||
"worker_start": worker_start, "worker_end": worker_time,
|
||
"rx_cpu_seconds": cpu, "packets": len(packets), "lag_seconds": lag})
|
||
if len(block_metrics) % 64 == 0:
|
||
rss_samples.append({"samples": total_samples, **process_memory()})
|
||
|
||
def push(values):
|
||
nonlocal pending
|
||
offset = 0
|
||
if pending.size:
|
||
needed = receiver.block_samples - pending.size
|
||
joined = np.concatenate((pending, values[:needed]))
|
||
offset = min(needed, values.size)
|
||
if joined.size < receiver.block_samples:
|
||
pending = joined
|
||
return
|
||
consume_block(joined)
|
||
pending = np.empty(0, np.complex64)
|
||
while offset + receiver.block_samples <= values.size:
|
||
consume_block(values[offset:offset + receiver.block_samples])
|
||
offset += receiver.block_samples
|
||
pending = values[offset:].copy()
|
||
|
||
def background(count):
|
||
while count:
|
||
amount = min(count, receiver.block_samples)
|
||
values = .2 * (random_generator.normal(size=amount) + 1j*random_generator.normal(size=amount))
|
||
push(values.astype(np.complex64))
|
||
count -= amount
|
||
|
||
for image in sequence:
|
||
if image.image_id not in starts:
|
||
continue
|
||
target = math.ceil(starts[image.image_id] * mode.sample_rate_hz)
|
||
background(max(0, target - total_samples - pending.size))
|
||
for frame in video.transport.build_object_frames(image.jpeg, image.image_id):
|
||
raw = video.phy.build_parameterized_tx_cs8(frame.plan, mode)
|
||
components = np.frombuffer(raw, np.int8).astype(np.float32)
|
||
values = components[0::2] + 1j*components[1::2]
|
||
indexes = np.arange(values.size)
|
||
values = values * np.exp(1j*(.73 + 2*np.pi*10*indexes/mode.sample_rate_hz)) * (-1 if tx_packets % 2 else 1)
|
||
energy = float(np.sum(abs(values)**2)) / frame.plan.frame_symbol_count
|
||
sigma = math.sqrt(energy / (2*10**(.7)))
|
||
values += sigma*(random_generator.normal(size=values.size) + 1j*random_generator.normal(size=values.size))
|
||
push(values.astype(np.complex64))
|
||
tx_packets += 1
|
||
background(max(0, math.ceil(horizon * mode.sample_rate_hz) + receiver.lookahead + receiver.core_samples
|
||
- total_samples - pending.size))
|
||
if pending.size:
|
||
consume_block(pending)
|
||
consume_block(np.empty(0,np.complex64), final=True)
|
||
fresh = [row for row in completed if row["fresh"]]
|
||
displayed_in_window = [row for row in fresh if row["decode_complete_timestamp"] <= horizon]
|
||
decoded_in_window = [row for row in completed if row["decode_complete_timestamp"] <= horizon]
|
||
ages = [row["image_age_seconds"] for row in completed if row["image_age_seconds"] is not None]
|
||
fresh_ages = [row["image_age_seconds"] for row in fresh]
|
||
processing = rx_processing + sink.assembly_seconds + sink.jpeg_decode_seconds
|
||
return {
|
||
"scope": "continuous synthetic IQ; measured per-block CPU event replay; camera/display latency UNKNOWN",
|
||
"symbol_rate":500000,"samples_per_symbol":16,"input_fps":5,"input_frames":len(sequence),
|
||
"observation_seconds":horizon,"iq_seconds":total_samples/mode.sample_rate_hz,
|
||
"tx_images":len(starts),"tx_physical_packets":tx_packets,
|
||
"dropped_before_tx":len(schedule.dropped_frames),"maximum_waiting_frames":schedule.maximum_waiting_frames,
|
||
"rx_physical_packets":receiver.packet_count,"received_images":len(completed),
|
||
"object_jpeg_integrity":len(completed)==len(starts) and all(row["sha_match"] for row in completed),
|
||
"dropped_stale_at_display":len(completed)-len(fresh),"displayed":len(fresh),"fresh_displayed":len(fresh),
|
||
"raw_received_fps":len(decoded_in_window)/horizon,
|
||
"raw_display_fps":len(displayed_in_window)/horizon,"fresh_display_fps":len(displayed_in_window)/horizon,
|
||
"drain_fresh_displayed":len(fresh)-len(displayed_in_window),
|
||
"image_age_min_seconds":min(ages) if ages else None,
|
||
"image_age_median_seconds":statistics.median(ages) if ages else None,
|
||
"image_age_max_seconds":max(ages) if ages else None,
|
||
"fresh_image_age_seconds":fresh_ages,"max_video_age_seconds":.2,
|
||
"processing_seconds":processing,"rx_processing_seconds":rx_processing,
|
||
"assembly_seconds":sink.assembly_seconds,"jpeg_decode_seconds":sink.jpeg_decode_seconds,
|
||
"processing_per_rf_second":processing/(total_samples/mode.sample_rate_hz),
|
||
"physical_packet_processing_seconds":processing/tx_packets if tx_packets else None,
|
||
"maximum_cpu_lag_seconds":max_lag,
|
||
"implied_external_iq_backlog_bytes":math.ceil(max_lag*mode.sample_rate_hz)*8,
|
||
"live_external_queue_implemented":False,
|
||
"retained_iq_peak_bytes":receiver.peak_buffer_samples*8,
|
||
"working_arrays_upper_estimate_bytes":receiver.memory_estimate_bytes + 20*receiver.buffer_limit_samples*8,
|
||
"object_payload_peak_bytes":sink.retained_payload_peak,"process_memory":process_memory(),
|
||
"rss_samples":rss_samples,"candidate_count":receiver.candidate_count,
|
||
"rejected_reasons":dict(receiver.rejected_reasons),"front_statistics":dict(receiver.front_statistics),
|
||
"decimated_samples_reused":receiver.decimator.reused_samples,
|
||
"actual_decimator_input_samples":receiver.decimator.filtered_input_samples,
|
||
"images":completed,"blocks":block_metrics,"rf_tx":0,
|
||
}
|
||
|
||
|
||
def run_memory_test(block_count: int = 4096) -> dict:
|
||
"""33,55 с без истории IQ/пакетов; нулевой фон отделён от noisy IQ опыта."""
|
||
receiver = fast.StreamingReceiver()
|
||
values = np.zeros(receiver.block_samples,np.complex64)
|
||
checkpoints=[]
|
||
for index in range(block_count):
|
||
assert receiver.push_iq_block(values) == []
|
||
assert receiver.buffer.size <= receiver.buffer_limit_samples
|
||
if (index+1) % 512 == 0:
|
||
checkpoints.append({"block":index+1,"iq_bytes":receiver.buffer.nbytes,
|
||
"decimated_bytes":receiver.decimator.values.nbytes,**process_memory()})
|
||
assert receiver.finish()==[]
|
||
return {"blocks":block_count,"model_seconds":block_count*values.size/8000000,
|
||
"background":"zeros; noisy background covered by six-second integration",
|
||
"peak_iq_bytes":receiver.peak_buffer_samples*8,"iq_limit_bytes":receiver.buffer_limit_samples*8,
|
||
"retained_after_finish":receiver.buffer.nbytes+receiver.decimator.values.nbytes,
|
||
"checkpoints":checkpoints,"rf_tx":0}
|
||
|
||
|
||
def run_candidate_replay() -> dict:
|
||
metadata=json.loads((OUTPUT/"optimized_examples.json").read_text(encoding="utf-8"))
|
||
result={}
|
||
for kind, entry in metadata.items():
|
||
raw=np.load(OUTPUT/f"optimized_{kind}_window.npy")
|
||
candidate=fast.offline.CoarseCandidate(entry["offset"],entry["score"])
|
||
def call(optimized=False):
|
||
with fast.previous.streaming_mode(video.phy.PhyMode(500000)):
|
||
def decode():
|
||
return fast.offline.decode_candidate_from_memmap(raw,entry["sample_count"],candidate,1,
|
||
read_iq_window=lambda data,start,end:data[2*start:2*end].view(np.complex64))
|
||
if not optimized:
|
||
return decode()
|
||
front=fast.BlockFrontEnd(raw.view(np.complex64),65536,Counter())
|
||
with fast.optimized_context(front): return decode()
|
||
old=call(False)
|
||
new=call(True)
|
||
assert (old.accepted,old.reason,old.refined_start_sample)==(new.accepted,new.reason,new.refined_start_sample)
|
||
result[kind]={"reason":new.reason,"baseline_arrays":array_observation(lambda:call(False)),
|
||
"optimized_arrays":array_observation(lambda:call(True))}
|
||
return result
|
||
|
||
|
||
def run_benchmark(sequence) -> dict:
|
||
original=video.StreamingReceiver
|
||
try:
|
||
video.StreamingReceiver=fast.StreamingReceiver
|
||
repeats=[video.benchmark_receiver(sequence[:3],500000) for unused in range(3)]
|
||
profiler=StageProfiler()
|
||
with profiler.measure(optimized=True): profiled=video.benchmark_receiver(sequence[:3],500000)
|
||
metadata={}
|
||
for kind,(raw,count,candidate) in profiler.examples.items():
|
||
np.save(OUTPUT/f"optimized_{kind}_window.npy",raw)
|
||
metadata[kind]={"sample_count":count,"offset":candidate.sample_offset,"score":candidate.score}
|
||
write_result("optimized_examples.json",metadata)
|
||
return {"repeats":repeats,"median_processing_seconds":statistics.median(row["processing_seconds"] for row in repeats),
|
||
"profile":profiler.summary(),"profiled_benchmark":profiled,"rf_tx":0}
|
||
finally:
|
||
video.StreamingReceiver=original
|
||
|
||
|
||
def run_functional_tests() -> None:
|
||
assert video.maximum_jpeg_bytes(500000,5)==4148
|
||
assert fast.lab045.conv.CONSTRAINT_LENGTH==7
|
||
|
||
|
||
def main() -> None:
|
||
parser=argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--stage",choices=("benchmark","stream","memory","replay","all"),default="all")
|
||
args=parser.parse_args()
|
||
OUTPUT.mkdir(parents=True,exist_ok=True)
|
||
with Image.open(ROOT/"data/raw/lab011_scene.jpg") as source:
|
||
sequence=video.encode_sequence(source,video.PROFILES[2],30)
|
||
for name,function in (("benchmark",lambda:run_benchmark(sequence)),("stream",lambda:run_stream(sequence)),
|
||
("memory",run_memory_test),("replay",run_candidate_replay)):
|
||
if args.stage not in ("all",name): continue
|
||
print(f"Lab051 {name}: START; RF TX=0",flush=True)
|
||
result=function()
|
||
write_result(f"{name}.json",result)
|
||
print(json.dumps({key:value for key,value in result.items() if key not in (
|
||
"blocks","images","repeats","profiled_benchmark","rss_samples")},ensure_ascii=False),flush=True)
|
||
|
||
|
||
if __name__=="__main__":
|
||
main()
|