feat(planning): consolidate recorded-route localization and spatial scene
Preserve the completed teach-and-repeat laboratory stage: reference preparation, cascaded acquisition, local tracking and recovery, recording lifecycle, replay qualification, and persistent Rerun scene controls. Document the open grid-picking regression and Rerun upgrade contract. No autonomous driving or loop-closure optimization is claimed.
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
"""Retained fMP4 through the real durable gateway and a bounded decoder reader.
|
||||
|
||||
The producer reads local files at original receipt cadence. It cannot connect to
|
||||
RTSP or send device commands. This qualifies downstream camera contention, not
|
||||
network transport, device authority, or browser MSE.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.camera import (
|
||||
XgridsK1CameraGateway,
|
||||
_CameraProducer,
|
||||
_read_fmp4_stdout,
|
||||
)
|
||||
from k1link.missions.causal_replay import digest
|
||||
from k1link.web.camera_archive import CameraArchiveWriter
|
||||
|
||||
|
||||
def camera_inputs(epoch):
|
||||
rows = [json.loads(line) for line in (epoch / "index.jsonl").read_text().splitlines()]
|
||||
assert rows
|
||||
inputs = {str(epoch / "index.jsonl"): digest(epoch / "index.jsonl")}
|
||||
if rows[0]["kind"] != "init":
|
||||
# The canonical August baseline indexes media only. Its init timestamp
|
||||
# is unavailable; deliver init at the first media receipt without
|
||||
# inventing a measured initialization latency. All media deltas survive.
|
||||
summary = json.loads((epoch / "summary.json").read_text())
|
||||
inputs[str(epoch / "summary.json")] = digest(epoch / "summary.json")
|
||||
rows.insert(0, dict(kind="init", path="init.mp4", sha256=summary["init_sha256"],
|
||||
host_monotonic_ns=rows[0]["host_monotonic_ns"]))
|
||||
previous = rows[0]["host_monotonic_ns"]
|
||||
for row in rows:
|
||||
path = (epoch / row["path"]).resolve()
|
||||
assert path.is_relative_to(epoch.resolve()), "Camera path escaped retained epoch."
|
||||
assert row["host_monotonic_ns"] >= previous
|
||||
previous = row["host_monotonic_ns"]
|
||||
assert digest(path) == row["sha256"]
|
||||
inputs[str(path)] = row["sha256"]
|
||||
assert (previous - rows[0]["host_monotonic_ns"]) / 1e9 <= 65
|
||||
return rows, inputs
|
||||
|
||||
|
||||
class ArchiveCamera:
|
||||
def __init__(self, epoch, root, ffmpeg):
|
||||
self.epoch, self.root, self.ffmpeg = epoch, root, ffmpeg
|
||||
self.rows, self.inputs = camera_inputs(epoch)
|
||||
self.gateway = None
|
||||
self.producer = None
|
||||
self.decoder = None
|
||||
self.threads = []
|
||||
self.files = []
|
||||
self.errors = []
|
||||
self.commits = []
|
||||
self.received = []
|
||||
|
||||
def start(self):
|
||||
self.root.mkdir(parents=True, exist_ok=False)
|
||||
self.started = time.monotonic_ns()
|
||||
# Explicit local emitter only; never use the gateway's RTSP spawn path.
|
||||
process = subprocess.Popen(
|
||||
[sys.executable, str(Path(__file__).resolve()), str(self.epoch)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.gateway = XgridsK1CameraGateway(
|
||||
self.root, "archive-qualification", committed_segment_observer=self.observe,
|
||||
)
|
||||
self.producer = _CameraProducer(
|
||||
1, "sensor.camera.right", process,
|
||||
CameraArchiveWriter(self.root, "sensor.camera.right", 1),
|
||||
)
|
||||
# Private instance uses the same parser/archive/queue implementation.
|
||||
# No singleton, active acquisition, authority ledger, or network endpoint.
|
||||
self.gateway._producer = self.producer
|
||||
self.gateway._generation = 1
|
||||
self.gateway._source_id = self.producer.source_id
|
||||
self.gateway._recording_root = self.root
|
||||
self.gateway._phase = "connecting"
|
||||
self.lease = self.gateway.open_delivery(1, require_recording=True)
|
||||
self.gateway.expect_source_end_for_device_stop() # Expected fixture EOF only.
|
||||
frames = (self.root / "decoded.framemd5").open("wb")
|
||||
errors = (self.root / "decode.log").open("wb")
|
||||
self.files.extend([frames, errors])
|
||||
self.decoder = subprocess.Popen(
|
||||
[str(self.ffmpeg), "-hide_banner", "-loglevel", "warning", "-threads", "1",
|
||||
"-i", "pipe:0", "-map", "0:v:0", "-an", "-f", "framemd5", "pipe:1"],
|
||||
stdin=subprocess.PIPE, stdout=frames, stderr=errors,
|
||||
)
|
||||
self.threads = [
|
||||
threading.Thread(target=self.decode, name="archive-camera-preview"),
|
||||
threading.Thread(
|
||||
target=_read_fmp4_stdout, args=(self.gateway, self.producer),
|
||||
name="archive-camera-parser",
|
||||
),
|
||||
]
|
||||
for thread in self.threads:
|
||||
thread.start()
|
||||
|
||||
def observe(self, segment):
|
||||
self.commits.append(dict(
|
||||
kind=segment.kind, sequence=segment.sequence,
|
||||
at_s=(time.monotonic_ns() - self.started) / 1e9,
|
||||
sha256=hashlib.sha256(segment.payload).hexdigest(),
|
||||
))
|
||||
|
||||
def decode(self):
|
||||
try:
|
||||
while (segment := self.lease.segments.get()) is not None:
|
||||
kind, payload = segment
|
||||
self.decoder.stdin.write(payload)
|
||||
self.decoder.stdin.flush()
|
||||
self.received.append(dict(kind=kind, sha256=hashlib.sha256(payload).hexdigest()))
|
||||
except Exception as exc:
|
||||
self.errors.append(f"{type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
self.decoder.stdin.close()
|
||||
|
||||
def close(self):
|
||||
if self.gateway is None:
|
||||
return
|
||||
for thread in self.threads:
|
||||
thread.join(3)
|
||||
self.gateway.close()
|
||||
for thread in self.threads:
|
||||
thread.join(3)
|
||||
for process in (self.producer.process, self.decoder):
|
||||
try:
|
||||
process.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=3)
|
||||
self.errors.append("Child exceeded cleanup deadline.")
|
||||
for stream in self.files:
|
||||
stream.close()
|
||||
if self.producer.process.stdout:
|
||||
self.producer.process.stdout.close()
|
||||
|
||||
def report(self):
|
||||
archive = self.root / "media/sensor.camera.right/epoch-1"
|
||||
summary = json.loads((archive / "summary.json").read_text())
|
||||
expected = [row["sha256"] for row in self.rows]
|
||||
frames = [line for line in (self.root / "decoded.framemd5").read_text().splitlines()
|
||||
if line and not line.startswith("#")]
|
||||
decoder_log = (self.root / "decode.log").read_text()
|
||||
checks = dict(
|
||||
archive_complete=summary["status"] == "complete",
|
||||
committed_all_exact_bytes=[item["sha256"] for item in self.commits] == expected,
|
||||
delivered_all_exact_bytes=[item["sha256"] for item in self.received] == expected,
|
||||
preview_not_retired=self.lease.failure_code is None,
|
||||
clean_decode=self.decoder.returncode == 0 and not decoder_log.strip() and bool(frames),
|
||||
workers_stopped=all(not t.is_alive() for t in self.threads),
|
||||
no_errors=not self.errors,
|
||||
input_unchanged=all(digest(Path(p)) == sha for p, sha in self.inputs.items()),
|
||||
)
|
||||
return dict(checks=checks, errors=self.errors, commits=self.commits,
|
||||
media_segments=len(self.rows) - 1, decoded_frames=len(frames),
|
||||
decoder_returncode=self.decoder.returncode, browser_mse_tested=False,
|
||||
rtsp_tested=False, acquisition_authority_tested=False)
|
||||
|
||||
|
||||
def emit(epoch):
|
||||
rows, _ = camera_inputs(epoch)
|
||||
started, origin = time.monotonic_ns(), rows[0]["host_monotonic_ns"]
|
||||
for row in rows:
|
||||
delay = started + row["host_monotonic_ns"] - origin - time.monotonic_ns()
|
||||
if delay > 0:
|
||||
time.sleep(delay / 1e9)
|
||||
sys.stdout.buffer.write((epoch / row["path"]).read_bytes())
|
||||
sys.stdout.buffer.flush()
|
||||
time.sleep(0.5) # Allow the disposable reader to drain before expected EOF.
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
emit(Path(sys.argv[1]))
|
||||
Reference in New Issue
Block a user