feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
@@ -1,11 +1,80 @@
|
||||
"""Host-owned compute handoff contracts."""
|
||||
|
||||
from .annotation_workspace import (
|
||||
ANNOTATION_WORKSPACE_SCHEMA,
|
||||
AnnotationWorkspace,
|
||||
AnnotationWorkspaceError,
|
||||
prepare_annotation_workspace,
|
||||
validate_annotation_workspace,
|
||||
)
|
||||
from .evaluation_pack import (
|
||||
ANNOTATION_CONTRACT_SCHEMA,
|
||||
EVALUATION_PACK_SCHEMA,
|
||||
EvaluationFrameRequest,
|
||||
EvaluationPackFrame,
|
||||
RecordedEvaluationPack,
|
||||
RecordedEvaluationPackError,
|
||||
prepare_recorded_evaluation_pack,
|
||||
validate_recorded_evaluation_pack,
|
||||
)
|
||||
from .fusion_epoch import (
|
||||
RecordedCalibratedFusion,
|
||||
RecordedCalibratedFusionStore,
|
||||
RecordedPerceptionOverlayMux,
|
||||
validate_recorded_calibrated_fusion,
|
||||
)
|
||||
from .integrated_perception import (
|
||||
IntegratedPerceptionOverlayStore,
|
||||
IntegratedPerceptionResult,
|
||||
validate_integrated_perception_result,
|
||||
)
|
||||
from .jobs import (
|
||||
COMPUTE_JOB_SCHEMA,
|
||||
CameraComputeJob,
|
||||
prepare_camera_compute_job,
|
||||
validate_camera_compute_job,
|
||||
)
|
||||
from .live_perception import (
|
||||
LIVE_INGRESS_SCHEMA,
|
||||
LIVE_INGRESS_WIRE_SCHEMA,
|
||||
TELEMETRY_SCHEMA,
|
||||
WORLD_STATE_SCHEMA,
|
||||
LatestWinsQueue,
|
||||
LiveIngressEvent,
|
||||
LivePerceptionIngress,
|
||||
QueueSnapshot,
|
||||
WorldStateProjector,
|
||||
classify_health,
|
||||
)
|
||||
from .live_replay_qualification import (
|
||||
LiveReplayQualificationResult,
|
||||
validate_live_replay_qualification_result,
|
||||
)
|
||||
from .multirate_perception_qualification import (
|
||||
MultiratePerceptionArtifact,
|
||||
MultiratePerceptionQualificationResult,
|
||||
validate_multirate_perception_qualification_result,
|
||||
)
|
||||
from .perception_epoch import (
|
||||
RecordedPerceptionEpochResult,
|
||||
RecordedPerceptionEpochStore,
|
||||
RecordedPerceptionVideo,
|
||||
validate_recorded_perception_epoch_result,
|
||||
)
|
||||
from .qualification import (
|
||||
DEFAULT_QUALIFICATION_FRAME_COUNT,
|
||||
QUALIFICATION_POLICY,
|
||||
QualificationFrame,
|
||||
RecordedQualificationSlice,
|
||||
RecordedQualificationSliceError,
|
||||
prepare_recorded_qualification_slice,
|
||||
validate_recorded_qualification_slice,
|
||||
)
|
||||
from .realtime_tracking_qualification import (
|
||||
RealtimeTrackingArtifact,
|
||||
RealtimeTrackingQualificationResult,
|
||||
validate_realtime_tracking_qualification_result,
|
||||
)
|
||||
from .results import (
|
||||
DetectionFrame,
|
||||
ObjectDetection,
|
||||
@@ -14,16 +83,79 @@ from .results import (
|
||||
RecordedPerceptionResult,
|
||||
validate_recorded_perception_result,
|
||||
)
|
||||
from .tracked_fusion_qualification import (
|
||||
TrackedFusionQualificationResult,
|
||||
validate_tracked_fusion_qualification_result,
|
||||
)
|
||||
from .tracking_qualification import (
|
||||
TrackingQualificationArtifact,
|
||||
TrackingQualificationResult,
|
||||
validate_tracking_qualification_result,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ANNOTATION_CONTRACT_SCHEMA",
|
||||
"ANNOTATION_WORKSPACE_SCHEMA",
|
||||
"AnnotationWorkspace",
|
||||
"AnnotationWorkspaceError",
|
||||
"COMPUTE_JOB_SCHEMA",
|
||||
"DEFAULT_QUALIFICATION_FRAME_COUNT",
|
||||
"EVALUATION_PACK_SCHEMA",
|
||||
"EvaluationFrameRequest",
|
||||
"EvaluationPackFrame",
|
||||
"LatestWinsQueue",
|
||||
"LIVE_INGRESS_SCHEMA",
|
||||
"LIVE_INGRESS_WIRE_SCHEMA",
|
||||
"LiveIngressEvent",
|
||||
"LivePerceptionIngress",
|
||||
"IntegratedPerceptionOverlayStore",
|
||||
"IntegratedPerceptionResult",
|
||||
"LiveReplayQualificationResult",
|
||||
"MultiratePerceptionArtifact",
|
||||
"MultiratePerceptionQualificationResult",
|
||||
"QUALIFICATION_POLICY",
|
||||
"QueueSnapshot",
|
||||
"RecordedCalibratedFusion",
|
||||
"RecordedCalibratedFusionStore",
|
||||
"RecordedEvaluationPack",
|
||||
"RecordedEvaluationPackError",
|
||||
"RecordedPerceptionOverlayMux",
|
||||
"validate_recorded_calibrated_fusion",
|
||||
"CameraComputeJob",
|
||||
"prepare_camera_compute_job",
|
||||
"prepare_annotation_workspace",
|
||||
"prepare_recorded_evaluation_pack",
|
||||
"validate_camera_compute_job",
|
||||
"RecordedPerceptionEpochResult",
|
||||
"RecordedPerceptionEpochStore",
|
||||
"RecordedPerceptionVideo",
|
||||
"QualificationFrame",
|
||||
"RealtimeTrackingArtifact",
|
||||
"RealtimeTrackingQualificationResult",
|
||||
"RecordedQualificationSlice",
|
||||
"RecordedQualificationSliceError",
|
||||
"validate_recorded_perception_epoch_result",
|
||||
"validate_live_replay_qualification_result",
|
||||
"validate_integrated_perception_result",
|
||||
"validate_multirate_perception_qualification_result",
|
||||
"prepare_recorded_qualification_slice",
|
||||
"DetectionFrame",
|
||||
"ObjectDetection",
|
||||
"RecordedPerceptionOverlayError",
|
||||
"RecordedPerceptionOverlayStore",
|
||||
"RecordedPerceptionResult",
|
||||
"TrackingQualificationArtifact",
|
||||
"TrackingQualificationResult",
|
||||
"TrackedFusionQualificationResult",
|
||||
"TELEMETRY_SCHEMA",
|
||||
"WORLD_STATE_SCHEMA",
|
||||
"WorldStateProjector",
|
||||
"classify_health",
|
||||
"validate_recorded_perception_result",
|
||||
"validate_recorded_evaluation_pack",
|
||||
"validate_recorded_qualification_slice",
|
||||
"validate_realtime_tracking_qualification_result",
|
||||
"validate_tracking_qualification_result",
|
||||
"validate_tracked_fusion_qualification_result",
|
||||
"validate_annotation_workspace",
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,777 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.device_plugins.xgrids_k1.analyze.valid_fov import (
|
||||
validate_k1_valid_fov_mask,
|
||||
)
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import validate_camera_compute_job
|
||||
from .qualification import validate_recorded_qualification_slice
|
||||
|
||||
EVALUATION_PACK_SCHEMA = "missioncore.perception-evaluation-pack/v1"
|
||||
EVALUATION_PACK_IDENTITY_SCHEMA = "missioncore.perception-evaluation-pack-identity/v1"
|
||||
ANNOTATION_CONTRACT_SCHEMA = "missioncore.perception-annotation-contract/v1"
|
||||
ANNOTATION_TEMPLATE_SCHEMA = "missioncore.perception-annotation-template/v1"
|
||||
SELECTION_POLICY = "reviewed-anchors-plus-temporal-clips/v1"
|
||||
MIN_EVALUATION_FRAMES = 16
|
||||
MAX_EVALUATION_FRAMES = 128
|
||||
MAX_MANIFEST_BYTES = 32 * 1024 * 1024
|
||||
MAX_TIMELINE_LINE_BYTES = 16 * 1024
|
||||
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_SAFE_GENERATION = re.compile(r"^evaluation-pack-[a-f0-9]{64}$")
|
||||
_SAFE_GROUP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||||
|
||||
|
||||
class RecordedEvaluationPackError(RuntimeError):
|
||||
"""A recorded-perception evaluation pack is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvaluationFrameRequest:
|
||||
frame_index: int
|
||||
role: str
|
||||
group_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EvaluationPackFrame:
|
||||
image_id: int
|
||||
frame_index: int
|
||||
sequence: int
|
||||
segment_sha256: str
|
||||
session_seconds: float
|
||||
role: str
|
||||
group_id: str
|
||||
raw_path: Path
|
||||
valid_fov_fill_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedEvaluationPack:
|
||||
generation_id: str
|
||||
root: Path
|
||||
manifest_path: Path
|
||||
annotation_contract_path: Path
|
||||
annotation_template_path: Path
|
||||
job_id: str
|
||||
input_sha256: str
|
||||
qualification_generation_id: str
|
||||
valid_fov_generation_id: str
|
||||
calibration_sha256: str
|
||||
frames: tuple[EvaluationPackFrame, ...]
|
||||
|
||||
|
||||
def prepare_recorded_evaluation_pack(
|
||||
*,
|
||||
job_root: Path,
|
||||
qualification_root: Path,
|
||||
valid_fov_root: Path,
|
||||
decoded_frames_root: Path,
|
||||
timeline_path: Path,
|
||||
output_root: Path,
|
||||
selection: tuple[EvaluationFrameRequest, ...],
|
||||
decoder_version: str,
|
||||
selection_document_sha256: str,
|
||||
producer_files: tuple[tuple[str, str], ...],
|
||||
) -> RecordedEvaluationPack:
|
||||
"""Seal exact raw/fixed-fill frames and an annotation contract for model A/B.
|
||||
|
||||
The pack is immutable. Human annotations are authored in a separate working
|
||||
copy of ``annotation-template.json`` and later sealed as their own generation.
|
||||
This keeps source imagery and the evolving review state from being conflated.
|
||||
"""
|
||||
|
||||
job = validate_camera_compute_job(job_root)
|
||||
qualification = validate_recorded_qualification_slice(
|
||||
qualification_root,
|
||||
job_root=job.job_root,
|
||||
)
|
||||
valid_fov = validate_k1_valid_fov_mask(valid_fov_root)
|
||||
if not isinstance(decoder_version, str) or not 1 <= len(decoder_version) <= 256:
|
||||
raise RecordedEvaluationPackError("decoder version is invalid")
|
||||
if _SHA256.fullmatch(selection_document_sha256) is None:
|
||||
raise RecordedEvaluationPackError("selection document SHA-256 is invalid")
|
||||
producer_identity = _validate_producer_files(producer_files)
|
||||
if (
|
||||
valid_fov.source_id != job.source_id
|
||||
or valid_fov.width != 800
|
||||
or valid_fov.height != 600
|
||||
):
|
||||
raise RecordedEvaluationPackError("valid-FOV does not match the camera job")
|
||||
selected = _validate_selection(selection, job.segment_count, qualification)
|
||||
timestamps = _read_timeline(
|
||||
timeline_path,
|
||||
job.segment_count,
|
||||
job.timeline_start_seconds,
|
||||
job.timeline_end_seconds,
|
||||
)
|
||||
frames_root = decoded_frames_root.expanduser().resolve(strict=True)
|
||||
if not frames_root.is_dir():
|
||||
raise RecordedEvaluationPackError("decoded frame root is not a directory")
|
||||
with Image.open(valid_fov.mask_path) as opened_mask:
|
||||
mask = opened_mask.copy()
|
||||
if mask.mode != "L" or mask.size != (valid_fov.width, valid_fov.height):
|
||||
raise RecordedEvaluationPackError("valid-FOV mask cannot be applied to the frames")
|
||||
|
||||
source_by_index = {frame.frame_index: frame for frame in qualification.frames}
|
||||
job_index = _read_job_index(job.job_root, job.source_id, job.codec_epoch)
|
||||
decoded: list[dict[str, Any]] = []
|
||||
for image_id, request in enumerate(selected, start=1):
|
||||
source = source_by_index.get(request.frame_index)
|
||||
row = job_index[request.frame_index]
|
||||
sequence = _nonnegative_integer(row.get("sequence"), "segment sequence")
|
||||
segment_sha256 = row.get("sha256")
|
||||
if (
|
||||
sequence != request.frame_index + 1
|
||||
or not isinstance(segment_sha256, str)
|
||||
or _SHA256.fullmatch(segment_sha256) is None
|
||||
or (source is not None and source.segment_sha256 != segment_sha256)
|
||||
):
|
||||
raise RecordedEvaluationPackError("selected frame identity changed")
|
||||
source_path = frames_root / f"frame-{request.frame_index:06d}.png"
|
||||
_confined_regular_file(source_path, frames_root)
|
||||
try:
|
||||
with Image.open(source_path) as opened:
|
||||
raw = opened.convert("RGB")
|
||||
except OSError as exc:
|
||||
raise RecordedEvaluationPackError("decoded evaluation frame is unavailable") from exc
|
||||
if raw.size != (valid_fov.width, valid_fov.height):
|
||||
raise RecordedEvaluationPackError("decoded evaluation frame dimensions changed")
|
||||
fill = Image.composite(raw, Image.new("RGB", raw.size, (0, 0, 0)), mask)
|
||||
decoded.append(
|
||||
{
|
||||
"image_id": image_id,
|
||||
"request": request,
|
||||
"sequence": sequence,
|
||||
"segment_sha256": segment_sha256,
|
||||
"session_seconds": timestamps[request.frame_index],
|
||||
"raw": raw,
|
||||
"fill": fill,
|
||||
"raw_rgb_sha256": _pixel_sha256(raw),
|
||||
"fill_rgb_sha256": _pixel_sha256(fill),
|
||||
}
|
||||
)
|
||||
|
||||
identity = {
|
||||
"schema_version": EVALUATION_PACK_IDENTITY_SCHEMA,
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"session_id": job.session_id,
|
||||
"source_id": job.source_id,
|
||||
"codec_epoch": job.codec_epoch,
|
||||
"qualification_generation_id": qualification.generation_id,
|
||||
"qualification_policy": qualification.policy,
|
||||
"valid_fov_generation_id": valid_fov.generation_id,
|
||||
"calibration_sha256": valid_fov.calibration_sha256,
|
||||
"calibration_slot": valid_fov.calibration_slot,
|
||||
"selection_policy": SELECTION_POLICY,
|
||||
"selection_document_sha256": selection_document_sha256,
|
||||
"preprocessing_profile": "fixed-valid-fov-fill/v1",
|
||||
"decoder_version": decoder_version,
|
||||
"producer_files": producer_identity,
|
||||
"resolution": [valid_fov.width, valid_fov.height],
|
||||
"frames": [
|
||||
{
|
||||
"image_id": item["image_id"],
|
||||
"frame_index": item["request"].frame_index,
|
||||
"sequence": item["sequence"],
|
||||
"segment_sha256": item["segment_sha256"],
|
||||
"session_seconds": item["session_seconds"],
|
||||
"role": item["request"].role,
|
||||
"group_id": item["request"].group_id,
|
||||
"raw_rgb_sha256": item["raw_rgb_sha256"],
|
||||
"valid_fov_fill_rgb_sha256": item["fill_rgb_sha256"],
|
||||
}
|
||||
for item in decoded
|
||||
],
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"evaluation-pack-{identity_sha256}"
|
||||
root = _prepare_private_directory(output_root)
|
||||
final = root / generation_id
|
||||
if final.exists():
|
||||
existing = validate_recorded_evaluation_pack(
|
||||
final,
|
||||
job_root=job.job_root,
|
||||
qualification_root=qualification.root,
|
||||
valid_fov_root=valid_fov.root,
|
||||
)
|
||||
if existing.input_sha256 != job.input_sha256:
|
||||
raise RecordedEvaluationPackError("evaluation generation collides with another input")
|
||||
return existing
|
||||
|
||||
staging = root / f".{generation_id}.{secrets.token_hex(12)}.incomplete"
|
||||
published = False
|
||||
try:
|
||||
(staging / "images" / "raw").mkdir(mode=0o700, parents=True)
|
||||
(staging / "images" / "valid-fov-fill").mkdir(mode=0o700, parents=True)
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
for item in decoded:
|
||||
image_id = int(item["image_id"])
|
||||
frame_index = item["request"].frame_index
|
||||
name = f"image-{image_id:03d}-frame-{frame_index:06d}.png"
|
||||
raw_path = staging / "images" / "raw" / name
|
||||
fill_path = staging / "images" / "valid-fov-fill" / name
|
||||
item["raw"].save(raw_path, format="PNG", optimize=False)
|
||||
item["fill"].save(fill_path, format="PNG", optimize=False)
|
||||
_fsync_file(raw_path)
|
||||
_fsync_file(fill_path)
|
||||
os.chmod(raw_path, 0o600)
|
||||
os.chmod(fill_path, 0o600)
|
||||
artifacts.extend((_artifact(raw_path, staging), _artifact(fill_path, staging)))
|
||||
|
||||
contract = _annotation_contract(identity_sha256)
|
||||
template = _annotation_template(generation_id, identity["frames"])
|
||||
contract_path = staging / "annotation-contract.json"
|
||||
template_path = staging / "annotation-template.json"
|
||||
write_json_atomic(contract_path, contract)
|
||||
write_json_atomic(template_path, template)
|
||||
os.chmod(contract_path, 0o600)
|
||||
os.chmod(template_path, 0o600)
|
||||
artifacts.extend((_artifact(contract_path, staging), _artifact(template_path, staging)))
|
||||
manifest = {
|
||||
"schema_version": EVALUATION_PACK_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"artifacts": artifacts,
|
||||
"annotation_state": {
|
||||
"state": "unannotated",
|
||||
"mutable_inside_pack": False,
|
||||
"workflow": (
|
||||
"copy annotation-template.json to a review workspace; seal reviewed "
|
||||
"annotations as a separate content-addressed ground-truth generation"
|
||||
),
|
||||
},
|
||||
}
|
||||
write_json_atomic(staging / "manifest.json", manifest)
|
||||
os.chmod(staging / "manifest.json", 0o600)
|
||||
_fsync_tree(staging)
|
||||
os.replace(staging, final)
|
||||
_fsync_directory(root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
return validate_recorded_evaluation_pack(
|
||||
final,
|
||||
job_root=job.job_root,
|
||||
qualification_root=qualification.root,
|
||||
valid_fov_root=valid_fov.root,
|
||||
)
|
||||
|
||||
|
||||
def validate_recorded_evaluation_pack(
|
||||
pack_root: Path,
|
||||
*,
|
||||
job_root: Path,
|
||||
qualification_root: Path,
|
||||
valid_fov_root: Path,
|
||||
) -> RecordedEvaluationPack:
|
||||
root = pack_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_GENERATION.fullmatch(root.name) is None:
|
||||
raise RecordedEvaluationPackError("evaluation pack root is invalid")
|
||||
manifest = _read_json_object(root / "manifest.json", root, MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != EVALUATION_PACK_SCHEMA
|
||||
or manifest.get("generation_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != EVALUATION_PACK_IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"evaluation-pack-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation pack identity is inconsistent")
|
||||
job = validate_camera_compute_job(job_root)
|
||||
qualification = validate_recorded_qualification_slice(
|
||||
qualification_root,
|
||||
job_root=job.job_root,
|
||||
)
|
||||
valid_fov = validate_k1_valid_fov_mask(valid_fov_root)
|
||||
if (
|
||||
identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("session_id") != job.session_id
|
||||
or identity.get("source_id") != job.source_id
|
||||
or identity.get("codec_epoch") != job.codec_epoch
|
||||
or identity.get("qualification_generation_id") != qualification.generation_id
|
||||
or identity.get("qualification_policy") != qualification.policy
|
||||
or identity.get("valid_fov_generation_id") != valid_fov.generation_id
|
||||
or identity.get("calibration_sha256") != valid_fov.calibration_sha256
|
||||
or identity.get("calibration_slot") != valid_fov.calibration_slot
|
||||
or identity.get("selection_policy") != SELECTION_POLICY
|
||||
or not isinstance(identity.get("selection_document_sha256"), str)
|
||||
or _SHA256.fullmatch(str(identity["selection_document_sha256"])) is None
|
||||
or identity.get("preprocessing_profile") != "fixed-valid-fov-fill/v1"
|
||||
or not isinstance(identity.get("decoder_version"), str)
|
||||
or not 1 <= len(str(identity["decoder_version"])) <= 256
|
||||
or identity.get("resolution") != [valid_fov.width, valid_fov.height]
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation pack bindings changed")
|
||||
producer_files = identity.get("producer_files")
|
||||
if not isinstance(producer_files, list):
|
||||
raise RecordedEvaluationPackError("evaluation producer identity is unavailable")
|
||||
normalized_producer = _validate_producer_files(
|
||||
tuple(
|
||||
(str(row.get("name")), str(row.get("sha256")))
|
||||
for row in producer_files
|
||||
if isinstance(row, dict)
|
||||
)
|
||||
)
|
||||
if producer_files != normalized_producer:
|
||||
raise RecordedEvaluationPackError("evaluation producer identity changed")
|
||||
rows = identity.get("frames")
|
||||
if (
|
||||
not isinstance(rows, list)
|
||||
or not MIN_EVALUATION_FRAMES <= len(rows) <= MAX_EVALUATION_FRAMES
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation pack frame set is invalid")
|
||||
artifact_rows = manifest.get("artifacts")
|
||||
if not isinstance(artifact_rows, list) or len(artifact_rows) != len(rows) * 2 + 2:
|
||||
raise RecordedEvaluationPackError("evaluation pack artifact set is invalid")
|
||||
artifacts: dict[str, dict[str, Any]] = {}
|
||||
for artifact in artifact_rows:
|
||||
if not isinstance(artifact, dict) or not isinstance(artifact.get("path"), str):
|
||||
raise RecordedEvaluationPackError("evaluation artifact descriptor is invalid")
|
||||
path_text = str(artifact["path"])
|
||||
if path_text in artifacts:
|
||||
raise RecordedEvaluationPackError("evaluation artifact is duplicated")
|
||||
path = root / path_text
|
||||
metadata = _confined_regular_file(path, root)
|
||||
digest = artifact.get("sha256")
|
||||
if (
|
||||
artifact.get("byte_length") != metadata.st_size
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or _sha256_file(path) != digest
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation artifact changed")
|
||||
artifacts[path_text] = artifact
|
||||
contract_path = root / "annotation-contract.json"
|
||||
template_path = root / "annotation-template.json"
|
||||
contract = _read_json_object(contract_path, root, MAX_MANIFEST_BYTES)
|
||||
template = _read_json_object(template_path, root, MAX_MANIFEST_BYTES)
|
||||
if (
|
||||
contract.get("schema_version") != ANNOTATION_CONTRACT_SCHEMA
|
||||
or contract.get("evaluation_identity_sha256") != identity_sha256
|
||||
or template.get("schema_version") != ANNOTATION_TEMPLATE_SCHEMA
|
||||
or template.get("evaluation_pack_id") != root.name
|
||||
or template.get("images") != rows
|
||||
or "annotation-contract.json" not in artifacts
|
||||
or "annotation-template.json" not in artifacts
|
||||
):
|
||||
raise RecordedEvaluationPackError("annotation contract changed")
|
||||
|
||||
qualification_indices = {frame.frame_index for frame in qualification.frames}
|
||||
job_index = _read_job_index(job.job_root, job.source_id, job.codec_epoch)
|
||||
with Image.open(valid_fov.mask_path) as opened_mask:
|
||||
validation_mask = opened_mask.copy()
|
||||
frames: list[EvaluationPackFrame] = []
|
||||
previous_index = -1
|
||||
temporal_groups: dict[str, list[int]] = {}
|
||||
for position, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
raise RecordedEvaluationPackError("evaluation frame descriptor is invalid")
|
||||
frame_index = row.get("frame_index")
|
||||
image_id = row.get("image_id")
|
||||
role = row.get("role")
|
||||
group_id = row.get("group_id")
|
||||
session_seconds = row.get("session_seconds")
|
||||
if (
|
||||
image_id != position
|
||||
or not isinstance(frame_index, int)
|
||||
or isinstance(frame_index, bool)
|
||||
or not previous_index < frame_index < job.segment_count
|
||||
or role not in {"anchor", "temporal"}
|
||||
or not isinstance(group_id, str)
|
||||
or _SAFE_GROUP.fullmatch(group_id) is None
|
||||
or not isinstance(session_seconds, (int, float))
|
||||
or isinstance(session_seconds, bool)
|
||||
or not math.isfinite(float(session_seconds))
|
||||
or not job.timeline_start_seconds <= float(session_seconds) <= job.timeline_end_seconds
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation frame ordering is invalid")
|
||||
if role == "anchor" and frame_index not in qualification_indices:
|
||||
raise RecordedEvaluationPackError("anchor escaped the qualification slice")
|
||||
if role == "temporal":
|
||||
temporal_groups.setdefault(group_id, []).append(frame_index)
|
||||
name = f"image-{image_id:03d}-frame-{frame_index:06d}.png"
|
||||
raw_relative = f"images/raw/{name}"
|
||||
fill_relative = f"images/valid-fov-fill/{name}"
|
||||
if raw_relative not in artifacts or fill_relative not in artifacts:
|
||||
raise RecordedEvaluationPackError("evaluation image artifacts are incomplete")
|
||||
raw_path = root / raw_relative
|
||||
fill_path = root / fill_relative
|
||||
with Image.open(raw_path) as raw_opened, Image.open(fill_path) as fill_opened:
|
||||
raw = raw_opened.convert("RGB")
|
||||
fill = fill_opened.convert("RGB")
|
||||
expected_fill = Image.composite(
|
||||
raw,
|
||||
Image.new("RGB", raw.size, (0, 0, 0)),
|
||||
validation_mask,
|
||||
)
|
||||
expected_segment_sha256 = job_index[frame_index].get("sha256")
|
||||
if (
|
||||
raw.size != (valid_fov.width, valid_fov.height)
|
||||
or fill.size != raw.size
|
||||
or _pixel_sha256(raw) != row.get("raw_rgb_sha256")
|
||||
or _pixel_sha256(fill) != row.get("valid_fov_fill_rgb_sha256")
|
||||
or _pixel_sha256(expected_fill) != row.get("valid_fov_fill_rgb_sha256")
|
||||
or not isinstance(row.get("segment_sha256"), str)
|
||||
or _SHA256.fullmatch(str(row["segment_sha256"])) is None
|
||||
or row.get("segment_sha256") != expected_segment_sha256
|
||||
or row.get("sequence") != frame_index + 1
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation frame pixels or identity changed")
|
||||
frames.append(
|
||||
EvaluationPackFrame(
|
||||
image_id=image_id,
|
||||
frame_index=frame_index,
|
||||
sequence=frame_index + 1,
|
||||
segment_sha256=str(row["segment_sha256"]),
|
||||
session_seconds=float(session_seconds),
|
||||
role=role,
|
||||
group_id=group_id,
|
||||
raw_path=raw_path,
|
||||
valid_fov_fill_path=fill_path,
|
||||
)
|
||||
)
|
||||
previous_index = frame_index
|
||||
for group in temporal_groups.values():
|
||||
if len(group) < 3 or any(
|
||||
right != left + 1 for left, right in zip(group, group[1:], strict=False)
|
||||
):
|
||||
raise RecordedEvaluationPackError("temporal group is not a consecutive clip")
|
||||
if len(temporal_groups) < 2:
|
||||
raise RecordedEvaluationPackError("evaluation pack lacks temporal coverage")
|
||||
return RecordedEvaluationPack(
|
||||
generation_id=root.name,
|
||||
root=root,
|
||||
manifest_path=root / "manifest.json",
|
||||
annotation_contract_path=contract_path,
|
||||
annotation_template_path=template_path,
|
||||
job_id=job.job_id,
|
||||
input_sha256=job.input_sha256,
|
||||
qualification_generation_id=qualification.generation_id,
|
||||
valid_fov_generation_id=valid_fov.generation_id,
|
||||
calibration_sha256=valid_fov.calibration_sha256,
|
||||
frames=tuple(frames),
|
||||
)
|
||||
|
||||
|
||||
def _validate_selection(
|
||||
selection: tuple[EvaluationFrameRequest, ...],
|
||||
source_count: int,
|
||||
qualification: Any,
|
||||
) -> tuple[EvaluationFrameRequest, ...]:
|
||||
if not MIN_EVALUATION_FRAMES <= len(selection) <= MAX_EVALUATION_FRAMES:
|
||||
raise RecordedEvaluationPackError("evaluation selection size is outside bounds")
|
||||
qualification_indices = {frame.frame_index for frame in qualification.frames}
|
||||
previous = -1
|
||||
temporal_groups: dict[str, list[int]] = {}
|
||||
anchors = 0
|
||||
for request in selection:
|
||||
if (
|
||||
not isinstance(request, EvaluationFrameRequest)
|
||||
or not isinstance(request.frame_index, int)
|
||||
or isinstance(request.frame_index, bool)
|
||||
or not previous < request.frame_index < source_count
|
||||
or request.role not in {"anchor", "temporal"}
|
||||
or _SAFE_GROUP.fullmatch(request.group_id) is None
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation selection is invalid")
|
||||
if request.role == "anchor":
|
||||
if request.frame_index not in qualification_indices:
|
||||
raise RecordedEvaluationPackError("anchor is outside the qualification slice")
|
||||
anchors += 1
|
||||
else:
|
||||
temporal_groups.setdefault(request.group_id, []).append(request.frame_index)
|
||||
previous = request.frame_index
|
||||
if anchors < MIN_EVALUATION_FRAMES:
|
||||
raise RecordedEvaluationPackError("evaluation selection has too few anchors")
|
||||
if len(temporal_groups) < 2:
|
||||
raise RecordedEvaluationPackError("evaluation selection needs at least two temporal clips")
|
||||
for group in temporal_groups.values():
|
||||
if len(group) < 3 or any(
|
||||
right != left + 1 for left, right in zip(group, group[1:], strict=False)
|
||||
):
|
||||
raise RecordedEvaluationPackError("temporal clip frames must be consecutive")
|
||||
return selection
|
||||
|
||||
|
||||
def _annotation_contract(evaluation_identity_sha256: str) -> dict[str, Any]:
|
||||
categories = [
|
||||
(1, "person", "thing"),
|
||||
(2, "bicycle", "thing"),
|
||||
(3, "motorcycle", "thing"),
|
||||
(4, "car", "thing"),
|
||||
(5, "heavy_vehicle", "thing"),
|
||||
(6, "building_structure", "stuff"),
|
||||
(7, "paved_road", "stuff"),
|
||||
(8, "sidewalk_curb", "stuff"),
|
||||
(9, "ground_dirt", "stuff"),
|
||||
(10, "grass_low_vegetation", "stuff"),
|
||||
(11, "tree_woody_vegetation", "stuff"),
|
||||
(12, "sky", "stuff"),
|
||||
(13, "static_obstacle", "thing"),
|
||||
(14, "animal", "thing"),
|
||||
(15, "other_background", "stuff"),
|
||||
]
|
||||
return {
|
||||
"schema_version": ANNOTATION_CONTRACT_SCHEMA,
|
||||
"evaluation_identity_sha256": evaluation_identity_sha256,
|
||||
"task": "2d-panoptic-and-instance-qualification",
|
||||
"categories": [
|
||||
{"id": category_id, "name": name, "kind": kind}
|
||||
for category_id, name, kind in categories
|
||||
],
|
||||
"label_map": {
|
||||
"outside_valid_fov": 0,
|
||||
"ambiguous_or_unresolvable": 255,
|
||||
"metric_category_ids": [category_id for category_id, _name, _kind in categories],
|
||||
},
|
||||
"policy": {
|
||||
"coverage": "label every resolvable pixel inside the fixed valid-FOV mask",
|
||||
"instances": (
|
||||
"annotate every identifiable thing instance; preserve occluded and truncated "
|
||||
"flags; use ambiguous=255 only when class or boundary cannot be resolved"
|
||||
),
|
||||
"lens_exterior": "must remain label 0 and is excluded from all accuracy metrics",
|
||||
"prelabels": (
|
||||
"model-assisted prelabels are permitted only as drafts; every accepted object "
|
||||
"and semantic region requires human review"
|
||||
),
|
||||
"review": "two-pass review: annotator complete, then reviewer accepted",
|
||||
},
|
||||
"required_metrics": {
|
||||
"semantic": ["per_class_iou", "macro_miou_present", "frequency_weighted_iou"],
|
||||
"instance": ["ap_50_95", "ap50", "ap75", "ar100", "per_class_recall"],
|
||||
"safety_proxies": [
|
||||
"person_vehicle_miss_rate",
|
||||
"false_large_instance_rate",
|
||||
"valid_fov_boundary_leakage",
|
||||
],
|
||||
"temporal": ["class_flicker_rate", "instance_id_switches", "mask_iou_jitter"],
|
||||
},
|
||||
"acceptance": {
|
||||
"unreviewed_frames": 0,
|
||||
"pixels_outside_valid_fov_nonzero": 0,
|
||||
"invalid_category_pixels": 0,
|
||||
"duplicate_instance_ids_per_frame": 0,
|
||||
"metric_reporting": "mean, p50, p95 and worst-frame identities where applicable",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _validate_producer_files(files: tuple[tuple[str, str], ...]) -> list[dict[str, str]]:
|
||||
if not 1 <= len(files) <= 8:
|
||||
raise RecordedEvaluationPackError("evaluation producer file set is invalid")
|
||||
normalized: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for name, digest in files:
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or _SAFE_GROUP.fullmatch(name) is None
|
||||
or name in seen
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation producer file identity is invalid")
|
||||
normalized.append({"name": name, "sha256": digest})
|
||||
seen.add(name)
|
||||
return normalized
|
||||
|
||||
|
||||
def _annotation_template(generation_id: str, frames: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": ANNOTATION_TEMPLATE_SCHEMA,
|
||||
"evaluation_pack_id": generation_id,
|
||||
"state": "unannotated",
|
||||
"images": frames,
|
||||
"semantic_masks": [],
|
||||
"instances": [],
|
||||
"reviews": [
|
||||
{
|
||||
"image_id": row["image_id"],
|
||||
"annotation_status": "unannotated",
|
||||
"review_status": "unreviewed",
|
||||
"notes": "",
|
||||
}
|
||||
for row in frames
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _read_timeline(path: Path, count: int, start: float, end: float) -> list[float]:
|
||||
root = path.expanduser().resolve(strict=True).parent
|
||||
resolved = path.expanduser().resolve(strict=True)
|
||||
_confined_regular_file(resolved, root)
|
||||
values: list[float] = []
|
||||
try:
|
||||
with resolved.open("r", encoding="utf-8") as stream:
|
||||
for expected, line in enumerate(stream):
|
||||
if len(line.encode("utf-8")) > MAX_TIMELINE_LINE_BYTES:
|
||||
raise RecordedEvaluationPackError("decoded timeline row is too large")
|
||||
row = json.loads(line)
|
||||
value = row.get("session_seconds") if isinstance(row, dict) else None
|
||||
if row.get("frame_index") != expected or not isinstance(value, (int, float)):
|
||||
raise RecordedEvaluationPackError("decoded timeline order changed")
|
||||
timestamp = float(value)
|
||||
if not math.isfinite(timestamp) or not start <= timestamp <= end:
|
||||
raise RecordedEvaluationPackError("decoded timeline escapes the job")
|
||||
if values and timestamp <= values[-1]:
|
||||
raise RecordedEvaluationPackError("decoded timeline is not monotonic")
|
||||
values.append(timestamp)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
||||
raise RecordedEvaluationPackError("decoded timeline is unavailable") from exc
|
||||
if len(values) != count:
|
||||
raise RecordedEvaluationPackError("decoded timeline frame count changed")
|
||||
return values
|
||||
|
||||
|
||||
def _read_job_index(job_root: Path, source_id: str, codec_epoch: int) -> list[dict[str, Any]]:
|
||||
path = job_root / "input" / "camera" / source_id / f"epoch-{codec_epoch}" / "index.jsonl"
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
row = json.loads(line)
|
||||
if not isinstance(row, dict):
|
||||
raise RecordedEvaluationPackError("camera index row is invalid")
|
||||
rows.append(row)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecordedEvaluationPackError("camera index is unavailable") from exc
|
||||
if not rows:
|
||||
raise RecordedEvaluationPackError("camera index is empty")
|
||||
return rows
|
||||
|
||||
|
||||
def _nonnegative_integer(value: Any, label: str) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise RecordedEvaluationPackError(f"{label} is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _pixel_sha256(image: Image.Image) -> str:
|
||||
digest = hashlib.sha256()
|
||||
digest.update(image.width.to_bytes(4, "big"))
|
||||
digest.update(image.height.to_bytes(4, "big"))
|
||||
digest.update(image.mode.encode("ascii"))
|
||||
digest.update(b"\x00")
|
||||
digest.update(image.tobytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _artifact(path: Path, root: Path) -> dict[str, Any]:
|
||||
return {
|
||||
"path": path.relative_to(root).as_posix(),
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": _sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def _prepare_private_directory(path: Path) -> Path:
|
||||
candidate = path.expanduser()
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
metadata = candidate.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise RecordedEvaluationPackError("evaluation output root must be a real directory")
|
||||
root = candidate.resolve(strict=True)
|
||||
os.chmod(root, 0o700)
|
||||
return root
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= maximum_bytes:
|
||||
raise RecordedEvaluationPackError("evaluation JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecordedEvaluationPackError("evaluation JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RecordedEvaluationPackError("evaluation JSON is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise RecordedEvaluationPackError("evaluation artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise RecordedEvaluationPackError("evaluation artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("evaluation identity is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_tree(root: Path) -> None:
|
||||
for path in sorted(root.rglob("*"), reverse=True):
|
||||
if path.is_file():
|
||||
_fsync_file(path)
|
||||
elif path.is_dir():
|
||||
_fsync_directory(path)
|
||||
_fsync_directory(root)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,542 @@
|
||||
"""Validated calibrated 3D fusion results projected into an opened Rerun recording."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun.components import FillMode
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
from .perception_epoch import validate_recorded_perception_epoch_result
|
||||
from .results import RecordedPerceptionOverlayError
|
||||
|
||||
FUSION_SCHEMA = "missioncore.recorded-calibrated-fusion/v1"
|
||||
FUSION_IDENTITY_SCHEMA = "missioncore.recorded-calibrated-fusion-identity/v1"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_SCAN = 512
|
||||
MAX_POINTS_PER_FRAME = 100_000
|
||||
MAX_BOXES_PER_FRAME = 10_000
|
||||
|
||||
_SAFE_FUSION_ID = re.compile(r"^fusion-[a-f0-9]{64}$")
|
||||
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedCalibratedFusion:
|
||||
fusion_id: str
|
||||
root: Path
|
||||
job: CameraComputeJob
|
||||
perception_result_id: str
|
||||
created_at_utc: str
|
||||
arrays_path: Path
|
||||
labels_path: Path
|
||||
frame_count: int
|
||||
|
||||
|
||||
class _OverlayProvider(Protocol):
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None: ...
|
||||
|
||||
|
||||
class RecordedCalibratedFusionStore:
|
||||
"""Discover full-epoch fusion and serialize it for the opened recording ID."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
jobs_root: Path,
|
||||
perception_results_root: Path,
|
||||
fusion_results_root: Path,
|
||||
cache_root: Path,
|
||||
) -> None:
|
||||
self.jobs_root = jobs_root.expanduser().absolute()
|
||||
self.perception_results_root = perception_results_root.expanduser().absolute()
|
||||
self.fusion_results_root = fusion_results_root.expanduser().absolute()
|
||||
self.cache_root = cache_root.expanduser().absolute()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
|
||||
raise ValueError("observation session id is invalid")
|
||||
if application_id != "nodedc_mission_core_recorded":
|
||||
raise ValueError("recorded fusion application id is invalid")
|
||||
if _SAFE_RECORDING_ID.fullmatch(recording_id) is None:
|
||||
raise ValueError("recorded fusion recording id is invalid")
|
||||
with self._lock:
|
||||
fusion = self._latest(session_id)
|
||||
if fusion is None:
|
||||
return None
|
||||
cache_root = _private_directory(self.cache_root)
|
||||
session_cache = _private_child_directory(cache_root, session_id)
|
||||
fusion_cache = _private_child_directory(session_cache, fusion.fusion_id)
|
||||
output = fusion_cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
cached = _read_cache(output, sidecar, fusion, recording_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
payload = _render_fusion(
|
||||
fusion,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, output)
|
||||
write_json_atomic(
|
||||
sidecar,
|
||||
{
|
||||
"schema_version": "missioncore.calibrated-fusion-overlay-cache/v1",
|
||||
"fusion_id": fusion.fusion_id,
|
||||
"recording_id": recording_id,
|
||||
"byte_length": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return payload
|
||||
|
||||
def _latest(self, session_id: str) -> RecordedCalibratedFusion | None:
|
||||
try:
|
||||
jobs = sorted(self.jobs_root.iterdir())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if len(jobs) > MAX_SCAN:
|
||||
raise RecordedPerceptionOverlayError("compute job catalog is outside bounds")
|
||||
matches: list[RecordedCalibratedFusion] = []
|
||||
for job_root in jobs:
|
||||
if job_root.is_symlink():
|
||||
continue
|
||||
try:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if job.session_id != session_id:
|
||||
continue
|
||||
parent = self.fusion_results_root / job.job_id
|
||||
try:
|
||||
candidates = sorted(
|
||||
path
|
||||
for path in parent.iterdir()
|
||||
if path.is_dir()
|
||||
and not path.is_symlink()
|
||||
and _SAFE_FUSION_ID.fullmatch(path.name) is not None
|
||||
)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
if len(candidates) > MAX_SCAN:
|
||||
raise RecordedPerceptionOverlayError("fusion result catalog is outside bounds")
|
||||
for candidate in candidates:
|
||||
try:
|
||||
matches.append(
|
||||
validate_recorded_calibrated_fusion(
|
||||
job_root,
|
||||
self.perception_results_root,
|
||||
candidate,
|
||||
)
|
||||
)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda value: (value.created_at_utc, value.fusion_id))
|
||||
|
||||
|
||||
class RecordedPerceptionOverlayMux:
|
||||
"""Prefer calibrated full-epoch 3D fusion and retain the legacy fallback."""
|
||||
|
||||
def __init__(self, primary: _OverlayProvider, fallback: _OverlayProvider | None) -> None:
|
||||
self.primary = primary
|
||||
self.fallback = fallback
|
||||
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
payload = self.primary.render(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
if payload is not None or self.fallback is None:
|
||||
return payload
|
||||
return self.fallback.render(
|
||||
session_id,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
)
|
||||
|
||||
|
||||
def validate_recorded_calibrated_fusion(
|
||||
job_root: Path,
|
||||
perception_results_root: Path,
|
||||
fusion_root: Path,
|
||||
) -> RecordedCalibratedFusion:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = fusion_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_FUSION_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("calibrated fusion root is invalid")
|
||||
manifest = _read_json_object(root / "manifest.json", root)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != FUSION_SCHEMA
|
||||
or manifest.get("fusion_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != FUSION_IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"fusion-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or manifest.get("session_id") != job.session_id
|
||||
or manifest.get("source_id") != job.source_id
|
||||
or manifest.get("frame_count") != job.segment_count
|
||||
or manifest.get("timeline_start_seconds") != job.timeline_start_seconds
|
||||
or manifest.get("timeline_end_seconds") != job.timeline_end_seconds
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion identity is inconsistent")
|
||||
perception_result_id = identity.get("perception_result_id")
|
||||
if not isinstance(perception_result_id, str):
|
||||
raise SessionIntegrityError("calibrated fusion perception binding is invalid")
|
||||
perception = validate_recorded_perception_epoch_result(
|
||||
job_root,
|
||||
perception_results_root / job.job_id / perception_result_id,
|
||||
)
|
||||
if (
|
||||
identity.get("calibration_sha256") != perception.calibration_sha256
|
||||
or identity.get("camera_slot") != perception.calibration_slot
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion calibration binding changed")
|
||||
artifacts = manifest.get("artifacts")
|
||||
expected = {"fusion.npz", "box-labels.json", "fusion-frames.jsonl"}
|
||||
if not isinstance(artifacts, list) or len(artifacts) != len(expected):
|
||||
raise SessionIntegrityError("calibrated fusion artifacts are incomplete")
|
||||
paths: dict[str, Path] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict) or artifact.get("name") not in expected:
|
||||
raise SessionIntegrityError("calibrated fusion artifact is invalid")
|
||||
name = str(artifact["name"])
|
||||
if name in paths:
|
||||
raise SessionIntegrityError("calibrated fusion artifact is duplicated")
|
||||
path = root / name
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if (
|
||||
artifact.get("byte_length") != metadata.st_size
|
||||
or not isinstance(artifact.get("sha256"), str)
|
||||
or _SHA256.fullmatch(str(artifact["sha256"])) is None
|
||||
or _sha256(path) != artifact["sha256"]
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion artifact identity changed")
|
||||
paths[name] = path
|
||||
labels = _read_labels(paths["box-labels.json"])
|
||||
_validate_arrays(paths["fusion.npz"], job, len(labels))
|
||||
created_at = manifest.get("created_at_utc")
|
||||
if not isinstance(created_at, str) or not 1 <= len(created_at) <= 64:
|
||||
raise SessionIntegrityError("calibrated fusion creation time is invalid")
|
||||
return RecordedCalibratedFusion(
|
||||
fusion_id=root.name,
|
||||
root=root,
|
||||
job=job,
|
||||
perception_result_id=perception_result_id,
|
||||
created_at_utc=created_at,
|
||||
arrays_path=paths["fusion.npz"],
|
||||
labels_path=paths["box-labels.json"],
|
||||
frame_count=job.segment_count,
|
||||
)
|
||||
|
||||
|
||||
def _validate_arrays(path: Path, job: CameraComputeJob, label_count: int) -> None:
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as arrays:
|
||||
required = {
|
||||
"frame_times_ns",
|
||||
"point_offsets",
|
||||
"points",
|
||||
"point_colors",
|
||||
"box_offsets",
|
||||
"box_centers",
|
||||
"box_half_sizes",
|
||||
"box_colors",
|
||||
}
|
||||
if set(arrays.files) != required:
|
||||
raise SessionIntegrityError("calibrated fusion array set is invalid")
|
||||
frame_times = arrays["frame_times_ns"]
|
||||
point_offsets = arrays["point_offsets"]
|
||||
points = arrays["points"]
|
||||
point_colors = arrays["point_colors"]
|
||||
box_offsets = arrays["box_offsets"]
|
||||
box_centers = arrays["box_centers"]
|
||||
box_half_sizes = arrays["box_half_sizes"]
|
||||
box_colors = arrays["box_colors"]
|
||||
if (
|
||||
frame_times.dtype != np.int64
|
||||
or frame_times.shape != (job.segment_count,)
|
||||
or point_offsets.dtype != np.int64
|
||||
or point_offsets.shape != (job.segment_count + 1,)
|
||||
or box_offsets.dtype != np.int64
|
||||
or box_offsets.shape != (job.segment_count + 1,)
|
||||
or points.dtype != np.float32
|
||||
or points.ndim != 2
|
||||
or points.shape[1:] != (3,)
|
||||
or point_colors.dtype != np.uint8
|
||||
or point_colors.shape != points.shape
|
||||
or box_centers.dtype != np.float32
|
||||
or box_centers.ndim != 2
|
||||
or box_centers.shape[1:] != (3,)
|
||||
or box_half_sizes.dtype != np.float32
|
||||
or box_half_sizes.shape != box_centers.shape
|
||||
or box_colors.dtype != np.uint8
|
||||
or box_colors.shape != (box_centers.shape[0], 4)
|
||||
or label_count != box_centers.shape[0]
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion array shapes changed")
|
||||
_validate_offsets(point_offsets, points.shape[0], MAX_POINTS_PER_FRAME)
|
||||
_validate_offsets(box_offsets, box_centers.shape[0], MAX_BOXES_PER_FRAME)
|
||||
if (
|
||||
np.any(np.diff(frame_times) <= 0)
|
||||
or frame_times[0] < round(job.timeline_start_seconds * 1e9) - 1_000_000
|
||||
or frame_times[-1] > round(job.timeline_end_seconds * 1e9) + 1_000_000
|
||||
or not np.isfinite(points).all()
|
||||
or not np.isfinite(box_centers).all()
|
||||
or not np.isfinite(box_half_sizes).all()
|
||||
or np.any(box_half_sizes <= 0)
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion arrays are inconsistent")
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
raise SessionIntegrityError("calibrated fusion arrays are unavailable") from exc
|
||||
|
||||
|
||||
def _validate_offsets(
|
||||
offsets: np.ndarray[Any, np.dtype[np.int64]],
|
||||
total: int,
|
||||
maximum: int,
|
||||
) -> None:
|
||||
if (
|
||||
offsets[0] != 0
|
||||
or offsets[-1] != total
|
||||
or np.any(np.diff(offsets) < 0)
|
||||
or np.any(np.diff(offsets) > maximum)
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion offsets are inconsistent")
|
||||
|
||||
|
||||
def _render_fusion(
|
||||
fusion: RecordedCalibratedFusion,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes:
|
||||
labels = _read_labels(fusion.labels_path)
|
||||
recording = rr.RecordingStream(application_id, recording_id=recording_id)
|
||||
stream = rr.binary_stream(recording)
|
||||
try:
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/perception/contract",
|
||||
rr.TextDocument(
|
||||
"Factory-calibrated KB4 mask-to-LiDAR diagnostic. Distances and support-gated "
|
||||
"boxes are not ground-truthed or safety accepted."
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
with np.load(fusion.arrays_path, allow_pickle=False) as arrays:
|
||||
frame_times = arrays["frame_times_ns"]
|
||||
point_offsets = arrays["point_offsets"]
|
||||
points = arrays["points"]
|
||||
point_colors = arrays["point_colors"]
|
||||
box_offsets = arrays["box_offsets"]
|
||||
centers = arrays["box_centers"]
|
||||
half_sizes = arrays["box_half_sizes"]
|
||||
box_colors = arrays["box_colors"]
|
||||
for index, timestamp in enumerate(frame_times):
|
||||
recording.set_time(
|
||||
SESSION_TIMELINE,
|
||||
duration=np.timedelta64(int(timestamp), "ns"),
|
||||
)
|
||||
point_start, point_end = int(point_offsets[index]), int(point_offsets[index + 1])
|
||||
if point_end > point_start:
|
||||
recording.log(
|
||||
"/world/perception/semantic_points",
|
||||
rr.Points3D(
|
||||
points[point_start:point_end],
|
||||
colors=point_colors[point_start:point_end],
|
||||
radii=rr.Radius.ui_points(2.5),
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log(
|
||||
"/world/perception/semantic_points",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
box_start, box_end = int(box_offsets[index]), int(box_offsets[index + 1])
|
||||
if box_end > box_start:
|
||||
recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Boxes3D(
|
||||
centers=centers[box_start:box_end],
|
||||
half_sizes=half_sizes[box_start:box_end],
|
||||
colors=box_colors[box_start:box_end],
|
||||
labels=labels[box_start:box_end],
|
||||
fill_mode=FillMode.Solid,
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
payload = stream.read(flush=True, flush_timeout_sec=300.0)
|
||||
except Exception as exc:
|
||||
raise RecordedPerceptionOverlayError("failed to serialize calibrated fusion") from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if payload is None or not payload.startswith(b"RRF2"):
|
||||
raise RecordedPerceptionOverlayError("serialized calibrated fusion is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _read_cache(
|
||||
output: Path,
|
||||
sidecar: Path,
|
||||
fusion: RecordedCalibratedFusion,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
try:
|
||||
value = _read_json_object(sidecar, sidecar.parent)
|
||||
payload = output.read_bytes()
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
if (
|
||||
value.get("schema_version") != "missioncore.calibrated-fusion-overlay-cache/v1"
|
||||
or value.get("fusion_id") != fusion.fusion_id
|
||||
or value.get("recording_id") != recording_id
|
||||
or value.get("byte_length") != len(payload)
|
||||
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||
or not payload.startswith(b"RRF2")
|
||||
):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_labels(path: Path) -> list[str]:
|
||||
metadata = _confined_regular_file(path, path.parent)
|
||||
if not 1 <= metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("calibrated fusion labels are outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("calibrated fusion labels are unavailable") from exc
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or any(not isinstance(item, str) or not 1 <= len(item) <= 256 for item in value)
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion labels are invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("calibrated fusion JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("calibrated fusion JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("calibrated fusion JSON is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("calibrated fusion artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("calibrated fusion artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("calibrated fusion identity cannot be encoded") from exc
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> Path:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise RecordedPerceptionOverlayError("fusion cache root is invalid")
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o700)
|
||||
return path.resolve(strict=True)
|
||||
|
||||
|
||||
def _private_child_directory(root: Path, name: str) -> Path:
|
||||
child = root / name
|
||||
child.mkdir(mode=0o700, exist_ok=True)
|
||||
if child.is_symlink() or not child.is_dir() or child.resolve(strict=True).parent != root:
|
||||
raise RecordedPerceptionOverlayError("fusion cache directory is invalid")
|
||||
with suppress(OSError):
|
||||
os.chmod(child, 0o700)
|
||||
return child.resolve(strict=True)
|
||||
@@ -0,0 +1,925 @@
|
||||
"""Validate and project accepted LAB E10 integrated perception results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol, TypeGuard
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun.components import FillMode
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
from .results import RecordedPerceptionOverlayError
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e10-integrated-perception-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e10-integrated-perception-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e10-integrated-perception-report/v1"
|
||||
PACK_SCHEMA = "missioncore.e10-lidar-replay-pack/v1"
|
||||
SEMANTIC_SCHEMA = "missioncore.e10-semantic-frame/v1"
|
||||
FUSION_SCHEMA = "missioncore.e10-fusion-frame/v1"
|
||||
WORLD_SCHEMA = "missioncore.live-perception-world-state/v1"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
MAX_SCAN = 512
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 4 * 1024 * 1024
|
||||
MAX_SOURCE_BYTES = 512 * 1024 * 1024
|
||||
OVERLAY_RENDERER_VERSION = "4"
|
||||
CUBOID_PRESENTATION_HOLD_NS = 500_000_000
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e10-integrated-perception-[a-f0-9]{64}$")
|
||||
_SAFE_PACK_ID = re.compile(r"^e10-lidar-pack-[a-f0-9]{64}$")
|
||||
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class IntegratedPerceptionResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
pack_root: Path
|
||||
created_at_utc: str
|
||||
accepted: bool
|
||||
publication_scope: str
|
||||
source_start_frame_index: int
|
||||
frame_count: int
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
semantic_path: Path
|
||||
fusion_path: Path
|
||||
world_path: Path
|
||||
arrays_path: Path
|
||||
report_path: Path
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _PresentedCuboid:
|
||||
observed_ns: int
|
||||
track_id: int
|
||||
label: str
|
||||
association_group: str
|
||||
distance_m: float
|
||||
support_points: int
|
||||
center: np.ndarray
|
||||
half_size: np.ndarray
|
||||
quaternion: np.ndarray
|
||||
color: np.ndarray
|
||||
|
||||
|
||||
class _CuboidPresentationState:
|
||||
"""Hold accepted cuboids briefly for operator presentation only.
|
||||
|
||||
The persisted world-state remains fail-closed and frame-exact. This bounded
|
||||
latest-at projection only prevents one rejected LiDAR association from
|
||||
visually clearing an otherwise stable tracked object for a single frame.
|
||||
"""
|
||||
|
||||
def __init__(self, hold_ns: int = CUBOID_PRESENTATION_HOLD_NS) -> None:
|
||||
if hold_ns <= 0:
|
||||
raise ValueError("cuboid presentation hold must be positive")
|
||||
self._hold_ns = hold_ns
|
||||
self._latest: dict[int, _PresentedCuboid] = {}
|
||||
|
||||
def update(
|
||||
self,
|
||||
timestamp_ns: int,
|
||||
objects: list[dict[str, Any]],
|
||||
centers: np.ndarray,
|
||||
half_sizes: np.ndarray,
|
||||
quaternions: np.ndarray,
|
||||
colors: np.ndarray,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, list[str]] | None:
|
||||
accepted = [
|
||||
item
|
||||
for item in objects
|
||||
if str(item.get("cuboid_status", "")).startswith("accepted-")
|
||||
]
|
||||
if not (
|
||||
len(accepted)
|
||||
== len(centers)
|
||||
== len(half_sizes)
|
||||
== len(quaternions)
|
||||
== len(colors)
|
||||
):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception cuboid presentation arrays are inconsistent"
|
||||
)
|
||||
for item, center, half_size, quaternion, color in zip(
|
||||
accepted,
|
||||
centers,
|
||||
half_sizes,
|
||||
quaternions,
|
||||
colors,
|
||||
strict=True,
|
||||
):
|
||||
track_id = item.get("track_id")
|
||||
if not isinstance(track_id, int) or isinstance(track_id, bool):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception cuboid track identity is invalid"
|
||||
)
|
||||
self._latest[track_id] = _PresentedCuboid(
|
||||
observed_ns=timestamp_ns,
|
||||
track_id=track_id,
|
||||
label=str(item.get("label", "object")),
|
||||
association_group=str(item.get("association_group", "object")),
|
||||
distance_m=float(item["distance_smoothed_m"]),
|
||||
support_points=int(item["clustered_points"]),
|
||||
center=np.asarray(center).copy(),
|
||||
half_size=np.asarray(half_size).copy(),
|
||||
quaternion=np.asarray(quaternion).copy(),
|
||||
color=np.asarray(color).copy(),
|
||||
)
|
||||
|
||||
expired = [
|
||||
track_id
|
||||
for track_id, cuboid in self._latest.items()
|
||||
if timestamp_ns - cuboid.observed_ns > self._hold_ns
|
||||
]
|
||||
for track_id in expired:
|
||||
del self._latest[track_id]
|
||||
if not self._latest:
|
||||
return None
|
||||
|
||||
presented = sorted(self._latest.values(), key=lambda cuboid: cuboid.track_id)
|
||||
presented_colors: list[np.ndarray] = []
|
||||
labels: list[str] = []
|
||||
for cuboid in presented:
|
||||
age_ns = max(0, timestamp_ns - cuboid.observed_ns)
|
||||
color = cuboid.color.copy()
|
||||
if age_ns > 0 and color.shape == (4,):
|
||||
fade = min(1.0, age_ns / self._hold_ns)
|
||||
color[3] = max(24, round(float(color[3]) * (1.0 - 0.55 * fade)))
|
||||
presented_colors.append(color)
|
||||
age_label = "" if age_ns == 0 else f" · hold {age_ns / 1_000_000:.0f} ms"
|
||||
labels.append(
|
||||
f"{cuboid.association_group} #{cuboid.track_id} {cuboid.label} · "
|
||||
f"{cuboid.distance_m:.1f} m · {cuboid.support_points} pts{age_label}"
|
||||
)
|
||||
return (
|
||||
np.stack([cuboid.center for cuboid in presented]),
|
||||
np.stack([cuboid.half_size for cuboid in presented]),
|
||||
np.stack([cuboid.quaternion for cuboid in presented]),
|
||||
np.stack(presented_colors),
|
||||
labels,
|
||||
)
|
||||
|
||||
|
||||
class _OverlayProvider(Protocol):
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None: ...
|
||||
|
||||
|
||||
def validate_integrated_perception_result(
|
||||
job_root: Path,
|
||||
result_root: Path,
|
||||
lidar_packs_root: Path,
|
||||
) -> IntegratedPerceptionResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("integrated perception root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e10-integrated-perception-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("session_id") != job.session_id
|
||||
or identity.get("source_id") != job.source_id
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope")
|
||||
not in {
|
||||
"recorded-integrated-realtime-qualification-only",
|
||||
"recorded-integrated-semantic-loss-negative-control-only",
|
||||
}
|
||||
or result.get("acceptance_state") not in {"accepted", "rejected"}
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception identity is inconsistent")
|
||||
selection = identity.get("selection")
|
||||
if not isinstance(selection, dict):
|
||||
raise SessionIntegrityError("integrated perception selection is missing")
|
||||
count = selection.get("frame_count")
|
||||
start = selection.get("source_start_frame_index")
|
||||
end = selection.get("source_end_frame_index")
|
||||
timeline_start = selection.get("timeline_start_seconds")
|
||||
timeline_end = selection.get("timeline_end_seconds")
|
||||
if (
|
||||
not isinstance(count, int)
|
||||
or isinstance(count, bool)
|
||||
or count < 2
|
||||
or not isinstance(start, int)
|
||||
or isinstance(start, bool)
|
||||
or not isinstance(end, int)
|
||||
or isinstance(end, bool)
|
||||
or end - start + 1 != count
|
||||
or start < 0
|
||||
or end >= job.segment_count
|
||||
or not _finite(timeline_start)
|
||||
or not _finite(timeline_end)
|
||||
or float(timeline_end) <= float(timeline_start)
|
||||
or result.get("frames_processed") != count
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception selection is invalid")
|
||||
pack_id = identity.get("lidar_pack_id")
|
||||
if not isinstance(pack_id, str) or _SAFE_PACK_ID.fullmatch(pack_id) is None:
|
||||
raise SessionIntegrityError("integrated perception LiDAR pack binding is invalid")
|
||||
pack_root = lidar_packs_root.expanduser().resolve(strict=True) / pack_id
|
||||
_validate_pack(pack_root, job, identity, count, start, end)
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
semantic_path = artifacts["e10-semantic-frames"]
|
||||
fusion_path = artifacts["e10-fusion-frames"]
|
||||
world_path = artifacts["e10-world-state"]
|
||||
arrays_path = artifacts["e10-transient-perception"]
|
||||
report_path = artifacts["e10-run-report"]
|
||||
semantic_rows = _read_rows(semantic_path, root, SEMANTIC_SCHEMA)
|
||||
fusion_rows = _read_rows(fusion_path, root, FUSION_SCHEMA)
|
||||
world_rows = _read_rows(world_path, root, WORLD_SCHEMA)
|
||||
if len(fusion_rows) != count or len(world_rows) != count or not semantic_rows:
|
||||
raise SessionIntegrityError("integrated perception frame counts are incomplete")
|
||||
for index, (fusion, world) in enumerate(zip(fusion_rows, world_rows, strict=True)):
|
||||
source_index = start + index
|
||||
if (
|
||||
fusion.get("frame_index") != index
|
||||
or fusion.get("source_frame_index") != source_index
|
||||
or world.get("frame_index") != index
|
||||
or world.get("source_frame_index") != source_index
|
||||
or not isinstance(fusion.get("objects"), list)
|
||||
or not isinstance(world.get("objects"), list)
|
||||
or world.get("object_count") != len(world["objects"])
|
||||
or not isinstance(world.get("delivery"), dict)
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception frame identity changed")
|
||||
semantic_indices: list[int] = []
|
||||
for row in semantic_rows:
|
||||
frame_index = row.get("frame_index")
|
||||
if not isinstance(frame_index, int) or isinstance(frame_index, bool):
|
||||
raise SessionIntegrityError("integrated perception semantic timeline is invalid")
|
||||
semantic_indices.append(frame_index)
|
||||
if (
|
||||
semantic_indices != sorted(set(semantic_indices))
|
||||
or semantic_indices[0] != 0
|
||||
or semantic_indices[-1] >= count
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception semantic timeline is invalid")
|
||||
_validate_arrays(arrays_path, count, semantic_rows, fusion_rows)
|
||||
report = _read_object(report_path, root)
|
||||
acceptance = report.get("acceptance")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("identity") != identity
|
||||
or report.get("ground_truth") is not False
|
||||
or not isinstance(acceptance, dict)
|
||||
or acceptance.get("accepted") is not (result.get("acceptance_state") == "accepted")
|
||||
or acceptance.get("navigation_or_safety_accepted") is not False
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception report is inconsistent")
|
||||
created = result.get("created_at_utc")
|
||||
if not isinstance(created, str) or not 1 <= len(created) <= 64:
|
||||
raise SessionIntegrityError("integrated perception creation time is invalid")
|
||||
return IntegratedPerceptionResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
pack_root=pack_root,
|
||||
created_at_utc=created,
|
||||
accepted=result.get("acceptance_state") == "accepted",
|
||||
publication_scope=str(result["publication_scope"]),
|
||||
source_start_frame_index=start,
|
||||
frame_count=count,
|
||||
timeline_start_seconds=float(timeline_start),
|
||||
timeline_end_seconds=float(timeline_end),
|
||||
semantic_path=semantic_path,
|
||||
fusion_path=fusion_path,
|
||||
world_path=world_path,
|
||||
arrays_path=arrays_path,
|
||||
report_path=report_path,
|
||||
)
|
||||
|
||||
|
||||
class IntegratedPerceptionOverlayStore:
|
||||
"""Prefer the newest accepted E10 result for a recorded session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
jobs_root: Path,
|
||||
results_root: Path,
|
||||
lidar_packs_root: Path,
|
||||
cache_root: Path,
|
||||
ffmpeg_path: Path,
|
||||
) -> None:
|
||||
self.jobs_root = jobs_root.expanduser().absolute()
|
||||
self.results_root = results_root.expanduser().absolute()
|
||||
self.lidar_packs_root = lidar_packs_root.expanduser().absolute()
|
||||
self.cache_root = cache_root.expanduser().absolute()
|
||||
self.ffmpeg_path = ffmpeg_path.expanduser().absolute()
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def render(
|
||||
self,
|
||||
session_id: str,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
if application_id != "nodedc_mission_core_recorded":
|
||||
raise ValueError("integrated perception application id is invalid")
|
||||
if (
|
||||
_SAFE_RECORDING_ID.fullmatch(session_id) is None
|
||||
or _SAFE_RECORDING_ID.fullmatch(recording_id) is None
|
||||
):
|
||||
raise ValueError("integrated perception recording id is invalid")
|
||||
with self._lock:
|
||||
result = self._latest(session_id)
|
||||
if result is None:
|
||||
return None
|
||||
cache = _private_child(
|
||||
_private_child(_private_directory(self.cache_root), session_id),
|
||||
result.result_id,
|
||||
)
|
||||
output = cache / f"{recording_id}.rrd"
|
||||
sidecar = output.with_suffix(".rrd.cache.json")
|
||||
cached = _read_cache(output, sidecar, result, recording_id)
|
||||
if cached is not None:
|
||||
return cached
|
||||
payload = _render(
|
||||
result,
|
||||
application_id=application_id,
|
||||
recording_id=recording_id,
|
||||
ffmpeg_path=self.ffmpeg_path,
|
||||
temporary_root=cache,
|
||||
)
|
||||
temporary = output.with_name(f".{output.name}.{os.getpid()}.tmp")
|
||||
try:
|
||||
with temporary.open("xb") as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, output)
|
||||
write_json_atomic(
|
||||
sidecar,
|
||||
{
|
||||
"schema_version": "missioncore.e10-overlay-cache/v1",
|
||||
"renderer_version": OVERLAY_RENDERER_VERSION,
|
||||
"result_id": result.result_id,
|
||||
"recording_id": recording_id,
|
||||
"byte_length": len(payload),
|
||||
"sha256": hashlib.sha256(payload).hexdigest(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
return payload
|
||||
|
||||
def _latest(self, session_id: str) -> IntegratedPerceptionResult | None:
|
||||
try:
|
||||
jobs = sorted(self.jobs_root.iterdir())
|
||||
candidates = sorted(self.results_root.iterdir())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if len(jobs) > MAX_SCAN or len(candidates) > MAX_SCAN:
|
||||
raise RecordedPerceptionOverlayError("integrated perception catalog is outside bounds")
|
||||
matches = []
|
||||
for job_root in jobs:
|
||||
if job_root.is_symlink():
|
||||
continue
|
||||
try:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if job.session_id != session_id:
|
||||
continue
|
||||
for candidate in candidates:
|
||||
if candidate.is_symlink() or _SAFE_RESULT_ID.fullmatch(candidate.name) is None:
|
||||
continue
|
||||
try:
|
||||
value = validate_integrated_perception_result(
|
||||
job_root,
|
||||
candidate,
|
||||
self.lidar_packs_root,
|
||||
)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if (
|
||||
value.accepted
|
||||
and value.publication_scope == "recorded-integrated-realtime-qualification-only"
|
||||
):
|
||||
matches.append(value)
|
||||
if not matches:
|
||||
return None
|
||||
return max(matches, key=lambda value: (value.created_at_utc, value.result_id))
|
||||
|
||||
|
||||
def _render(
|
||||
result: IntegratedPerceptionResult,
|
||||
*,
|
||||
application_id: str,
|
||||
recording_id: str,
|
||||
ffmpeg_path: Path,
|
||||
temporary_root: Path,
|
||||
) -> bytes:
|
||||
fusion_rows = _read_rows(result.fusion_path, result.result_root, FUSION_SCHEMA)
|
||||
with np.load(result.arrays_path, allow_pickle=False) as arrays:
|
||||
frame_times = arrays["frame_times_ns"]
|
||||
semantic_indices = arrays["semantic_frame_indices"]
|
||||
semantic_masks = arrays["semantic_masks"]
|
||||
semantic_by_frame = {
|
||||
int(index): mask for index, mask in zip(semantic_indices, semantic_masks, strict=True)
|
||||
}
|
||||
support_offsets = arrays["support_offsets"]
|
||||
support = arrays["support_points"]
|
||||
support_colors = arrays["support_colors"]
|
||||
box_offsets = arrays["box_offsets"]
|
||||
centers = arrays["box_centers"]
|
||||
half_sizes = arrays["box_half_sizes"]
|
||||
quaternions = arrays["box_quaternions"]
|
||||
box_colors = arrays["box_colors"]
|
||||
cuboid_presentation = _CuboidPresentationState()
|
||||
recording = rr.RecordingStream(application_id, recording_id=recording_id)
|
||||
stream = rr.binary_stream(recording)
|
||||
source_path: Path | None = None
|
||||
proxy_path: Path | None = None
|
||||
try:
|
||||
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
|
||||
recording.log(
|
||||
"/world/perception/contract",
|
||||
rr.TextDocument(
|
||||
"LAB E10 integrated source-paced replay. Generic AI and host-arrival "
|
||||
"synchronization are diagnostic, not navigation or safety accepted."
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
source_path = _camera_source_file(
|
||||
result.job, result.source_start_frame_index + result.frame_count, temporary_root
|
||||
)
|
||||
source_end_frame_index = result.source_start_frame_index + result.frame_count - 1
|
||||
proxy_path = _camera_proxy_file(
|
||||
source_path,
|
||||
result.source_start_frame_index,
|
||||
source_end_frame_index,
|
||||
ffmpeg_path,
|
||||
temporary_root,
|
||||
)
|
||||
video = rr.AssetVideo(path=proxy_path)
|
||||
video_timestamps = video.read_frame_timestamps_nanos()
|
||||
if len(video_timestamps) != len(frame_times):
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception video frame count changed"
|
||||
)
|
||||
recording.log(
|
||||
"/perception/camera/image",
|
||||
video,
|
||||
static=True,
|
||||
)
|
||||
for index, timestamp in enumerate(frame_times):
|
||||
recording.set_time(SESSION_TIMELINE, duration=np.timedelta64(int(timestamp), "ns"))
|
||||
recording.log(
|
||||
"/perception/camera/image",
|
||||
rr.VideoFrameReference(nanoseconds=int(video_timestamps[index])),
|
||||
)
|
||||
mask = semantic_by_frame.get(index)
|
||||
if mask is not None:
|
||||
recording.log(
|
||||
"/perception/camera/segmentation",
|
||||
rr.SegmentationImage(mask),
|
||||
)
|
||||
objects = fusion_rows[index]["objects"]
|
||||
if objects:
|
||||
recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Boxes2D(
|
||||
array=[item["bbox_xyxy"] for item in objects],
|
||||
array_format=rr.Box2DFormat.XYXY,
|
||||
labels=[
|
||||
f"#{item['track_id']} {item['label']} · {float(item['score']):.0%}"
|
||||
for item in objects
|
||||
],
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log("/perception/camera/detections", rr.Clear(recursive=False))
|
||||
point_start, point_end = (
|
||||
int(support_offsets[index]),
|
||||
int(support_offsets[index + 1]),
|
||||
)
|
||||
if point_end > point_start:
|
||||
recording.log(
|
||||
"/world/perception/support",
|
||||
rr.Points3D(
|
||||
support[point_start:point_end],
|
||||
colors=support_colors[point_start:point_end],
|
||||
radii=rr.Radius.ui_points(3.0),
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log("/world/perception/support", rr.Clear(recursive=False))
|
||||
box_start, box_end = int(box_offsets[index]), int(box_offsets[index + 1])
|
||||
presented_cuboids = cuboid_presentation.update(
|
||||
int(timestamp),
|
||||
objects,
|
||||
centers[box_start:box_end],
|
||||
half_sizes[box_start:box_end],
|
||||
quaternions[box_start:box_end],
|
||||
box_colors[box_start:box_end],
|
||||
)
|
||||
if presented_cuboids is not None:
|
||||
(
|
||||
presented_centers,
|
||||
presented_half_sizes,
|
||||
presented_quaternions,
|
||||
presented_colors,
|
||||
presented_labels,
|
||||
) = presented_cuboids
|
||||
recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Boxes3D(
|
||||
centers=presented_centers,
|
||||
half_sizes=presented_half_sizes,
|
||||
quaternions=presented_quaternions,
|
||||
colors=presented_colors,
|
||||
labels=presented_labels,
|
||||
fill_mode=FillMode.Solid,
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
recording.log("/world/perception/boxes3d", rr.Clear(recursive=False))
|
||||
payload = stream.read(flush=True, flush_timeout_sec=300.0)
|
||||
except RecordedPerceptionOverlayError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"failed to serialize integrated perception"
|
||||
) from exc
|
||||
finally:
|
||||
with suppress(Exception):
|
||||
recording.disconnect()
|
||||
if source_path is not None:
|
||||
source_path.unlink(missing_ok=True)
|
||||
if proxy_path is not None:
|
||||
proxy_path.unlink(missing_ok=True)
|
||||
if payload is None or not payload.startswith(b"RRF2"):
|
||||
raise RecordedPerceptionOverlayError("integrated perception Rerun stream is invalid")
|
||||
return payload
|
||||
|
||||
|
||||
def _camera_source_file(job: CameraComputeJob, segment_count: int, root: Path) -> Path:
|
||||
epoch = job.job_root / "input" / "camera" / job.source_id / f"epoch-{job.codec_epoch}"
|
||||
paths = [epoch / "init.mp4"] + [
|
||||
epoch / "segments" / f"{sequence}.m4s" for sequence in range(1, segment_count + 1)
|
||||
]
|
||||
total = sum(path.stat().st_size for path in paths)
|
||||
if total <= 0 or total > MAX_SOURCE_BYTES:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
"integrated perception camera source is outside bounds"
|
||||
)
|
||||
descriptor, name = tempfile.mkstemp(prefix=".e10-camera-", suffix=".mp4", dir=root)
|
||||
path = Path(name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb") as output:
|
||||
for source in paths:
|
||||
with source.open("rb") as stream:
|
||||
shutil.copyfileobj(stream, output, length=1024 * 1024)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
return path
|
||||
except BaseException:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _camera_proxy_file(
|
||||
source: Path,
|
||||
start_frame_index: int,
|
||||
end_frame_index: int,
|
||||
ffmpeg_path: Path,
|
||||
root: Path,
|
||||
) -> Path:
|
||||
descriptor, name = tempfile.mkstemp(prefix=".e10-camera-proxy-", suffix=".mp4", dir=root)
|
||||
os.close(descriptor)
|
||||
path = Path(name)
|
||||
path.unlink(missing_ok=True)
|
||||
frame_filter = (
|
||||
f"select=between(n\\,{start_frame_index}\\,{end_frame_index}),setpts=N/(10*TB)"
|
||||
)
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(ffmpeg_path),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(source),
|
||||
"-vf",
|
||||
frame_filter,
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"veryfast",
|
||||
"-crf",
|
||||
"28",
|
||||
"-g",
|
||||
"20",
|
||||
"-keyint_min",
|
||||
"20",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-movflags",
|
||||
"+faststart",
|
||||
str(path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=300,
|
||||
)
|
||||
if completed.returncode != 0 or not path.is_file() or path.stat().st_size <= 0:
|
||||
raise RecordedPerceptionOverlayError(
|
||||
f"integrated perception video proxy failed: {completed.stderr[-1000:]!r}"
|
||||
)
|
||||
os.chmod(path, 0o600)
|
||||
return path
|
||||
except BaseException:
|
||||
path.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _validate_pack(
|
||||
root: Path,
|
||||
job: CameraComputeJob,
|
||||
result_identity: dict[str, Any],
|
||||
count: int,
|
||||
start: int,
|
||||
end: int,
|
||||
) -> None:
|
||||
if not root.is_dir() or _SAFE_PACK_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("integrated perception LiDAR pack root is invalid")
|
||||
manifest = _read_object(root / "manifest.json", root)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
arrays_path = root / "lidar-pack.npz"
|
||||
artifact = manifest.get("artifact")
|
||||
if (
|
||||
manifest.get("schema_version") != PACK_SCHEMA
|
||||
or manifest.get("pack_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != PACK_SCHEMA
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("calibration_sha256")
|
||||
!= result_identity["configuration"]["profile"]["source"]["calibration_sha256"]
|
||||
or identity.get("frame_count") != count
|
||||
or identity.get("source_start_frame_index") != start
|
||||
or identity.get("source_end_frame_index") != end
|
||||
or not isinstance(identity_sha256, str)
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or root.name != f"e10-lidar-pack-{identity_sha256}"
|
||||
or not isinstance(artifact, dict)
|
||||
or artifact.get("path") != arrays_path.name
|
||||
or artifact.get("byte_length") != arrays_path.stat().st_size
|
||||
or artifact.get("sha256") != _sha256(arrays_path)
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception LiDAR pack is inconsistent")
|
||||
|
||||
|
||||
def _validate_artifacts(root: Path, raw: object) -> dict[str, Path]:
|
||||
expected = {
|
||||
"e10-semantic-frames": ("semantic-frames.jsonl", SEMANTIC_SCHEMA),
|
||||
"e10-fusion-frames": ("fusion-frames.jsonl", FUSION_SCHEMA),
|
||||
"e10-world-state": ("world-state.jsonl", WORLD_SCHEMA),
|
||||
"e10-transient-perception": ("transient-perception.npz", None),
|
||||
"worker-gpu-telemetry": ("gpu-telemetry.jsonl", None),
|
||||
"e10-run-report": ("run-report.json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("integrated perception artifact set is incomplete")
|
||||
result = {}
|
||||
for value in raw:
|
||||
kind = value.get("kind") if isinstance(value, dict) else None
|
||||
if not isinstance(kind, str) or kind not in expected or kind in result:
|
||||
raise SessionIntegrityError("integrated perception artifact descriptor is invalid")
|
||||
name, schema = expected[kind]
|
||||
path = root / name
|
||||
metadata = _confined_file(path, root)
|
||||
if (
|
||||
value.get("path") != name
|
||||
or value.get("schema_version") != schema
|
||||
or value.get("byte_length") != metadata.st_size
|
||||
or value.get("sha256") != _sha256(path)
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception artifact identity changed")
|
||||
result[kind] = path
|
||||
return result
|
||||
|
||||
|
||||
def _validate_arrays(
|
||||
path: Path,
|
||||
count: int,
|
||||
semantic_rows: list[dict[str, Any]],
|
||||
fusion_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
with np.load(path, allow_pickle=False) as arrays:
|
||||
required = {
|
||||
"frame_times_ns",
|
||||
"semantic_frame_indices",
|
||||
"semantic_masks",
|
||||
"support_offsets",
|
||||
"support_points",
|
||||
"support_colors",
|
||||
"box_offsets",
|
||||
"box_centers",
|
||||
"box_half_sizes",
|
||||
"box_quaternions",
|
||||
"box_colors",
|
||||
}
|
||||
if set(arrays.files) != required:
|
||||
raise SessionIntegrityError("integrated perception array set changed")
|
||||
times = arrays["frame_times_ns"]
|
||||
semantic_indices = arrays["semantic_frame_indices"]
|
||||
masks = arrays["semantic_masks"]
|
||||
support_offsets = arrays["support_offsets"]
|
||||
support = arrays["support_points"]
|
||||
support_colors = arrays["support_colors"]
|
||||
box_offsets = arrays["box_offsets"]
|
||||
centers = arrays["box_centers"]
|
||||
half_sizes = arrays["box_half_sizes"]
|
||||
quaternions = arrays["box_quaternions"]
|
||||
colors = arrays["box_colors"]
|
||||
expected_boxes = sum(
|
||||
sum(
|
||||
str(item.get("cuboid_status", "")).startswith("accepted-")
|
||||
for item in row["objects"]
|
||||
)
|
||||
for row in fusion_rows
|
||||
)
|
||||
if (
|
||||
times.dtype != np.int64
|
||||
or times.shape != (count,)
|
||||
or np.any(np.diff(times) <= 0)
|
||||
or semantic_indices.dtype != np.int64
|
||||
or semantic_indices.shape != (len(semantic_rows),)
|
||||
or not np.array_equal(semantic_indices, [row["frame_index"] for row in semantic_rows])
|
||||
or masks.dtype != np.uint8
|
||||
or masks.shape != (len(semantic_rows), 600, 800)
|
||||
or support_offsets.shape != (count + 1,)
|
||||
or support.shape[1:] != (3,)
|
||||
or support_colors.shape != support.shape
|
||||
or box_offsets.shape != (count + 1,)
|
||||
or centers.shape != (expected_boxes, 3)
|
||||
or half_sizes.shape != centers.shape
|
||||
or quaternions.shape != (expected_boxes, 4)
|
||||
or colors.shape != (expected_boxes, 4)
|
||||
or int(support_offsets[-1]) != support.shape[0]
|
||||
or int(box_offsets[-1]) != expected_boxes
|
||||
or np.any(np.diff(support_offsets) < 0)
|
||||
or np.any(np.diff(box_offsets) < 0)
|
||||
or not np.isfinite(support).all()
|
||||
or not np.isfinite(centers).all()
|
||||
or not np.isfinite(half_sizes).all()
|
||||
or np.any(half_sizes <= 0)
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception arrays are inconsistent")
|
||||
|
||||
|
||||
def _read_rows(path: Path, root: Path, schema: str) -> list[dict[str, Any]]:
|
||||
_confined_file(path, root)
|
||||
rows = []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
if len(line.encode()) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("integrated perception row is oversized")
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict) or value.get("schema_version") != schema:
|
||||
raise SessionIntegrityError("integrated perception row schema changed")
|
||||
rows.append(value)
|
||||
return rows
|
||||
|
||||
|
||||
def _read_cache(
|
||||
output: Path,
|
||||
sidecar: Path,
|
||||
result: IntegratedPerceptionResult,
|
||||
recording_id: str,
|
||||
) -> bytes | None:
|
||||
try:
|
||||
value = _read_object(sidecar, sidecar.parent)
|
||||
payload = output.read_bytes()
|
||||
except (OSError, SessionIntegrityError):
|
||||
return None
|
||||
if (
|
||||
value.get("schema_version") != "missioncore.e10-overlay-cache/v1"
|
||||
or value.get("renderer_version") != OVERLAY_RENDERER_VERSION
|
||||
or value.get("result_id") != result.result_id
|
||||
or value.get("recording_id") != recording_id
|
||||
or value.get("byte_length") != len(payload)
|
||||
or value.get("sha256") != hashlib.sha256(payload).hexdigest()
|
||||
or not payload.startswith(b"RRF2")
|
||||
):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("integrated perception JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("integrated perception JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("integrated perception JSON is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("integrated perception artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("integrated perception artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _finite(value: object) -> TypeGuard[int | float]:
|
||||
return (
|
||||
isinstance(value, int | float)
|
||||
and not isinstance(value, bool)
|
||||
and bool(np.isfinite(value))
|
||||
)
|
||||
|
||||
|
||||
def _private_directory(path: Path) -> Path:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
raise RecordedPerceptionOverlayError("integrated perception cache root is invalid")
|
||||
with suppress(OSError):
|
||||
os.chmod(path, 0o700)
|
||||
return path.resolve(strict=True)
|
||||
|
||||
|
||||
def _private_child(root: Path, name: str) -> Path:
|
||||
child = root / name
|
||||
child.mkdir(mode=0o700, exist_ok=True)
|
||||
if child.is_symlink() or not child.is_dir() or child.resolve(strict=True).parent != root:
|
||||
raise RecordedPerceptionOverlayError("integrated perception cache child is invalid")
|
||||
with suppress(OSError):
|
||||
os.chmod(child, 0o700)
|
||||
return child.resolve(strict=True)
|
||||
@@ -0,0 +1,991 @@
|
||||
"""Bounded latest-wins primitives for derived live perception."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import statistics
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
import zlib
|
||||
from collections import deque
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
from typing import Any, Final, Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView
|
||||
|
||||
WORLD_STATE_SCHEMA = "missioncore.live-perception-world-state/v1"
|
||||
TELEMETRY_SCHEMA = "missioncore.live-perception-telemetry/v1"
|
||||
|
||||
HealthState = Literal["healthy", "degraded", "stale", "unavailable"]
|
||||
LiveIngressModality = Literal[
|
||||
"control",
|
||||
"camera-init",
|
||||
"camera-frame",
|
||||
"lidar",
|
||||
"pose",
|
||||
]
|
||||
|
||||
LIVE_INGRESS_SCHEMA: Final = "missioncore.live-perception-ingress/v1"
|
||||
LIVE_INGRESS_WIRE_SCHEMA: Final = "missioncore.live-perception-wire/v1"
|
||||
LIVE_RESULT_WIRE_SCHEMA: Final = "missioncore.live-perception-result-wire/v1"
|
||||
LIVE_RESULT_MAGIC: Final = b"MCPR"
|
||||
LIVE_RESULT_MAX_HEADER_BYTES: Final = 256 * 1024
|
||||
LIVE_RESULT_MAX_PAYLOAD_BYTES: Final = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LivePerceptionResultFrame:
|
||||
frame_index: int
|
||||
source_frame_index: int
|
||||
session_seconds: float
|
||||
captured_at_epoch_ns: int
|
||||
image_jpeg: bytes
|
||||
segmentation_mask: np.ndarray[Any, np.dtype[np.uint8]] | None
|
||||
objects: tuple[dict[str, Any], ...]
|
||||
delivery: dict[str, Any]
|
||||
|
||||
|
||||
def encode_live_perception_result(
|
||||
*,
|
||||
frame_index: int,
|
||||
source_frame_index: int,
|
||||
session_seconds: float,
|
||||
captured_at_epoch_ns: int,
|
||||
image_jpeg: bytes,
|
||||
segmentation_mask: np.ndarray[Any, Any] | None,
|
||||
objects: Sequence[Mapping[str, Any]],
|
||||
delivery: Mapping[str, Any],
|
||||
) -> bytes:
|
||||
"""Encode one bounded, non-authoritative worker-to-viewer result frame."""
|
||||
|
||||
if (
|
||||
frame_index < 0
|
||||
or source_frame_index < 0
|
||||
or captured_at_epoch_ns < 0
|
||||
or not math.isfinite(session_seconds)
|
||||
or session_seconds < 0
|
||||
or not 4 <= len(image_jpeg) <= 1024 * 1024
|
||||
or not image_jpeg.startswith(b"\xff\xd8")
|
||||
or not image_jpeg.endswith(b"\xff\xd9")
|
||||
):
|
||||
raise ValueError("live perception result identity or image is invalid")
|
||||
normalized_objects = tuple(_normalize_live_result_object(value) for value in objects)
|
||||
if len(normalized_objects) > 128:
|
||||
raise ValueError("live perception result object count exceeds the bound")
|
||||
mask_payload = b""
|
||||
mask_shape: list[int] | None = None
|
||||
if segmentation_mask is not None:
|
||||
mask = np.asarray(segmentation_mask, dtype=np.uint8)
|
||||
if mask.shape != (600, 800):
|
||||
raise ValueError("live perception segmentation shape is invalid")
|
||||
mask_payload = zlib.compress(mask.tobytes(order="C"), level=1)
|
||||
mask_shape = [600, 800]
|
||||
payload = bytes(image_jpeg) + mask_payload
|
||||
if len(payload) > LIVE_RESULT_MAX_PAYLOAD_BYTES:
|
||||
raise ValueError("live perception result payload exceeds the bound")
|
||||
header = {
|
||||
"schema_version": LIVE_RESULT_WIRE_SCHEMA,
|
||||
"frame_index": frame_index,
|
||||
"source_frame_index": source_frame_index,
|
||||
"session_seconds": session_seconds,
|
||||
"captured_at_epoch_ns": captured_at_epoch_ns,
|
||||
"image": {"codec": "jpeg", "byte_length": len(image_jpeg)},
|
||||
"segmentation": (
|
||||
None
|
||||
if mask_shape is None
|
||||
else {
|
||||
"codec": "zlib-uint8-c1",
|
||||
"shape": mask_shape,
|
||||
"byte_length": len(mask_payload),
|
||||
}
|
||||
),
|
||||
"objects": normalized_objects,
|
||||
"delivery": dict(delivery),
|
||||
"payload_bytes": len(payload),
|
||||
"payload_sha256": sha256(payload).hexdigest(),
|
||||
"authority": "shadow-diagnostic-only",
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
encoded_header = json.dumps(
|
||||
header,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
if len(encoded_header) > LIVE_RESULT_MAX_HEADER_BYTES:
|
||||
raise ValueError("live perception result header exceeds the bound")
|
||||
return LIVE_RESULT_MAGIC + struct.pack("!I", len(encoded_header)) + encoded_header + payload
|
||||
|
||||
|
||||
def decode_live_perception_result(encoded: bytes) -> LivePerceptionResultFrame:
|
||||
"""Validate and decode one result frame before it reaches the Rerun bridge."""
|
||||
|
||||
if len(encoded) < 10 or not encoded.startswith(LIVE_RESULT_MAGIC):
|
||||
raise ValueError("live perception result frame is truncated")
|
||||
header_length = struct.unpack("!I", encoded[4:8])[0]
|
||||
if not 2 <= header_length <= LIVE_RESULT_MAX_HEADER_BYTES:
|
||||
raise ValueError("live perception result header length is invalid")
|
||||
boundary = 8 + header_length
|
||||
if boundary > len(encoded):
|
||||
raise ValueError("live perception result header is truncated")
|
||||
try:
|
||||
header = json.loads(encoded[8:boundary])
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("live perception result header is invalid") from exc
|
||||
payload = encoded[boundary:]
|
||||
if (
|
||||
not isinstance(header, dict)
|
||||
or header.get("schema_version") != LIVE_RESULT_WIRE_SCHEMA
|
||||
or header.get("authority") != "shadow-diagnostic-only"
|
||||
or header.get("commands_enabled") is not False
|
||||
or header.get("navigation_or_safety_accepted") is not False
|
||||
or header.get("payload_bytes") != len(payload)
|
||||
or len(payload) > LIVE_RESULT_MAX_PAYLOAD_BYTES
|
||||
or header.get("payload_sha256") != sha256(payload).hexdigest()
|
||||
):
|
||||
raise ValueError("live perception result contract is invalid")
|
||||
image = header.get("image")
|
||||
segmentation = header.get("segmentation")
|
||||
objects = header.get("objects")
|
||||
delivery = header.get("delivery")
|
||||
if (
|
||||
not isinstance(image, dict)
|
||||
or image.get("codec") != "jpeg"
|
||||
or not isinstance(image.get("byte_length"), int)
|
||||
or not isinstance(objects, list)
|
||||
or len(objects) > 128
|
||||
or not isinstance(delivery, dict)
|
||||
):
|
||||
raise ValueError("live perception result content descriptor is invalid")
|
||||
image_length = image["byte_length"]
|
||||
if not 4 <= image_length <= min(len(payload), 1024 * 1024):
|
||||
raise ValueError("live perception result image length is invalid")
|
||||
image_jpeg = payload[:image_length]
|
||||
if not image_jpeg.startswith(b"\xff\xd8") or not image_jpeg.endswith(b"\xff\xd9"):
|
||||
raise ValueError("live perception result JPEG is invalid")
|
||||
mask: np.ndarray[Any, np.dtype[np.uint8]] | None = None
|
||||
if segmentation is None:
|
||||
if len(payload) != image_length:
|
||||
raise ValueError("live perception result has an undescribed payload tail")
|
||||
else:
|
||||
if (
|
||||
not isinstance(segmentation, dict)
|
||||
or segmentation.get("codec") != "zlib-uint8-c1"
|
||||
or segmentation.get("shape") != [600, 800]
|
||||
or not isinstance(segmentation.get("byte_length"), int)
|
||||
or segmentation["byte_length"] != len(payload) - image_length
|
||||
):
|
||||
raise ValueError("live perception segmentation descriptor is invalid")
|
||||
try:
|
||||
raw_mask = zlib.decompress(payload[image_length:])
|
||||
except zlib.error as exc:
|
||||
raise ValueError("live perception segmentation payload is invalid") from exc
|
||||
if len(raw_mask) != 600 * 800:
|
||||
raise ValueError("live perception segmentation byte length is invalid")
|
||||
mask = np.frombuffer(raw_mask, dtype=np.uint8).reshape((600, 800)).copy()
|
||||
normalized_objects = tuple(_normalize_live_result_object(value) for value in objects)
|
||||
frame_index = header.get("frame_index")
|
||||
source_frame_index = header.get("source_frame_index")
|
||||
session_seconds = header.get("session_seconds")
|
||||
captured_at_epoch_ns = header.get("captured_at_epoch_ns")
|
||||
if (
|
||||
not isinstance(frame_index, int)
|
||||
or isinstance(frame_index, bool)
|
||||
or frame_index < 0
|
||||
or not isinstance(source_frame_index, int)
|
||||
or isinstance(source_frame_index, bool)
|
||||
or source_frame_index < 0
|
||||
or not isinstance(captured_at_epoch_ns, int)
|
||||
or isinstance(captured_at_epoch_ns, bool)
|
||||
or captured_at_epoch_ns < 0
|
||||
or not isinstance(session_seconds, (int, float))
|
||||
or isinstance(session_seconds, bool)
|
||||
or not math.isfinite(float(session_seconds))
|
||||
or float(session_seconds) < 0
|
||||
):
|
||||
raise ValueError("live perception result time identity is invalid")
|
||||
return LivePerceptionResultFrame(
|
||||
frame_index=frame_index,
|
||||
source_frame_index=source_frame_index,
|
||||
session_seconds=float(session_seconds),
|
||||
captured_at_epoch_ns=captured_at_epoch_ns,
|
||||
image_jpeg=image_jpeg,
|
||||
segmentation_mask=mask,
|
||||
objects=normalized_objects,
|
||||
delivery=dict(delivery),
|
||||
)
|
||||
|
||||
|
||||
def _normalize_live_result_object(value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("live perception result object is invalid")
|
||||
track_id = value.get("track_id")
|
||||
label = value.get("label")
|
||||
score = value.get("score")
|
||||
bbox = value.get("bbox_xyxy")
|
||||
if (
|
||||
not isinstance(track_id, int)
|
||||
or isinstance(track_id, bool)
|
||||
or track_id < 0
|
||||
or not isinstance(label, str)
|
||||
or not 1 <= len(label) <= 64
|
||||
or not isinstance(score, (int, float))
|
||||
or isinstance(score, bool)
|
||||
or not math.isfinite(float(score))
|
||||
or not isinstance(bbox, Sequence)
|
||||
or isinstance(bbox, (str, bytes))
|
||||
or len(bbox) != 4
|
||||
):
|
||||
raise ValueError("live perception result object identity is invalid")
|
||||
bbox_values = [float(item) for item in bbox]
|
||||
if not all(math.isfinite(item) for item in bbox_values):
|
||||
raise ValueError("live perception result 2D box is invalid")
|
||||
normalized: dict[str, Any] = {
|
||||
"track_id": track_id,
|
||||
"label": label,
|
||||
"score": float(score),
|
||||
"bbox_xyxy": bbox_values,
|
||||
}
|
||||
distance = value.get("distance_smoothed_m", value.get("distance_median_m"))
|
||||
if distance is not None:
|
||||
if (
|
||||
not isinstance(distance, (int, float))
|
||||
or isinstance(distance, bool)
|
||||
or not math.isfinite(float(distance))
|
||||
or float(distance) < 0
|
||||
):
|
||||
raise ValueError("live perception result distance is invalid")
|
||||
normalized["distance_m"] = float(distance)
|
||||
else:
|
||||
normalized["distance_m"] = None
|
||||
cuboid_fields = (
|
||||
("cuboid_center_map", 3),
|
||||
("cuboid_half_size", 3),
|
||||
("cuboid_quaternion_xyzw", 4),
|
||||
)
|
||||
present = [value.get(name) is not None for name, _ in cuboid_fields]
|
||||
if any(present) and not all(present):
|
||||
raise ValueError("live perception result cuboid is incomplete")
|
||||
for name, length in cuboid_fields:
|
||||
candidate = value.get(name)
|
||||
if candidate is None:
|
||||
normalized[name] = None
|
||||
continue
|
||||
if (
|
||||
not isinstance(candidate, Sequence)
|
||||
or isinstance(candidate, (str, bytes))
|
||||
or len(candidate) != length
|
||||
):
|
||||
raise ValueError("live perception result cuboid geometry is invalid")
|
||||
values = [float(item) for item in candidate]
|
||||
if not all(math.isfinite(item) for item in values):
|
||||
raise ValueError("live perception result cuboid contains non-finite values")
|
||||
normalized[name] = values
|
||||
return normalized
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveIngressEvent:
|
||||
"""One raw-first, derived-only event admitted to the shadow transport."""
|
||||
|
||||
ingress_sequence: int
|
||||
session_id: str
|
||||
modality: LiveIngressModality
|
||||
source_id: str
|
||||
source_sequence: int
|
||||
captured_at_epoch_ns: int
|
||||
received_monotonic_ns: int
|
||||
payload: bytes
|
||||
|
||||
def wire_bytes(self) -> bytes:
|
||||
header = json.dumps(
|
||||
{
|
||||
"schema_version": LIVE_INGRESS_WIRE_SCHEMA,
|
||||
"ingress_sequence": self.ingress_sequence,
|
||||
"session_id": self.session_id,
|
||||
"modality": self.modality,
|
||||
"source_id": self.source_id,
|
||||
"source_sequence": self.source_sequence,
|
||||
"captured_at_epoch_ns": self.captured_at_epoch_ns,
|
||||
"received_monotonic_ns": self.received_monotonic_ns,
|
||||
"payload_bytes": len(self.payload),
|
||||
"payload_sha256": sha256(self.payload).hexdigest(),
|
||||
"authority": "shadow-diagnostic-only",
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
},
|
||||
ensure_ascii=True,
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
return struct.pack("!I", len(header)) + header + self.payload
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveIngressQueueSnapshot:
|
||||
capacity: int
|
||||
depth: int
|
||||
maximum_depth: int
|
||||
published: int
|
||||
consumed: int
|
||||
dropped_overflow: int
|
||||
rejected_oversize: int
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _LiveIngressQueue:
|
||||
capacity: int
|
||||
items: deque[LiveIngressEvent]
|
||||
maximum_depth: int = 0
|
||||
published: int = 0
|
||||
consumed: int = 0
|
||||
dropped_overflow: int = 0
|
||||
rejected_oversize: int = 0
|
||||
|
||||
|
||||
class LivePerceptionIngress:
|
||||
"""Exclusive, bounded fan-out from committed K1 evidence to one AI worker.
|
||||
|
||||
The ingress is deliberately not an acquisition source and has no command
|
||||
surface. Camera and MQTT producers call it only after their raw evidence
|
||||
commit has completed. Separate modality queues prevent camera bursts from
|
||||
evicting pose or LiDAR observations.
|
||||
"""
|
||||
|
||||
_CAPACITIES: Final[dict[LiveIngressModality, int]] = {
|
||||
"control": 4,
|
||||
"camera-init": 1,
|
||||
"camera-frame": 2,
|
||||
"lidar": 2,
|
||||
"pose": 16,
|
||||
}
|
||||
_MAX_PAYLOAD_BYTES: Final[dict[LiveIngressModality, int]] = {
|
||||
"control": 16 * 1024,
|
||||
"camera-init": 1024 * 1024,
|
||||
"camera-frame": 1024 * 1024,
|
||||
"lidar": 2 * 1024 * 1024,
|
||||
"pose": 2 * 1024 * 1024,
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._condition = threading.Condition()
|
||||
self._queues = {
|
||||
modality: _LiveIngressQueue(capacity, deque())
|
||||
for modality, capacity in self._CAPACITIES.items()
|
||||
}
|
||||
self._ingress_sequence = 0
|
||||
self._session_id: str | None = None
|
||||
self._active = False
|
||||
self._closed = False
|
||||
self._consumer_id: str | None = None
|
||||
|
||||
def begin_session(self, session_id: str) -> None:
|
||||
if not session_id or len(session_id) > 160:
|
||||
raise ValueError("live perception session id is invalid")
|
||||
with self._condition:
|
||||
if self._closed:
|
||||
raise RuntimeError("live perception ingress is closed")
|
||||
if self._active:
|
||||
if self._session_id == session_id:
|
||||
return
|
||||
raise RuntimeError("another live perception session is active")
|
||||
self._session_id = session_id
|
||||
self._active = True
|
||||
self._publish_locked(
|
||||
modality="control",
|
||||
source_id="mission-core",
|
||||
source_sequence=0,
|
||||
captured_at_epoch_ns=time.time_ns(),
|
||||
received_monotonic_ns=time.monotonic_ns(),
|
||||
payload=b'{"event":"session-start"}',
|
||||
)
|
||||
|
||||
def end_session(self, session_id: str) -> None:
|
||||
with self._condition:
|
||||
if not self._active or self._session_id != session_id:
|
||||
return
|
||||
self._publish_locked(
|
||||
modality="control",
|
||||
source_id="mission-core",
|
||||
source_sequence=0,
|
||||
captured_at_epoch_ns=time.time_ns(),
|
||||
received_monotonic_ns=time.monotonic_ns(),
|
||||
payload=b'{"event":"session-end"}',
|
||||
)
|
||||
self._active = False
|
||||
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
modality: LiveIngressModality,
|
||||
source_id: str,
|
||||
source_sequence: int,
|
||||
captured_at_epoch_ns: int,
|
||||
received_monotonic_ns: int,
|
||||
payload: bytes,
|
||||
) -> bool:
|
||||
if modality == "control":
|
||||
raise ValueError("control events are owned by the ingress lifecycle")
|
||||
if not source_id or source_sequence < 0:
|
||||
raise ValueError("live perception source identity is invalid")
|
||||
if captured_at_epoch_ns < 0 or received_monotonic_ns < 0:
|
||||
raise ValueError("live perception timestamps must be non-negative")
|
||||
with self._condition:
|
||||
if self._closed or not self._active:
|
||||
return False
|
||||
return self._publish_locked(
|
||||
modality=modality,
|
||||
source_id=source_id,
|
||||
source_sequence=source_sequence,
|
||||
captured_at_epoch_ns=captured_at_epoch_ns,
|
||||
received_monotonic_ns=received_monotonic_ns,
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
def open_consumer(self, consumer_id: str) -> None:
|
||||
if not consumer_id or len(consumer_id) > 128:
|
||||
raise ValueError("live perception consumer id is invalid")
|
||||
with self._condition:
|
||||
if self._closed:
|
||||
raise RuntimeError("live perception ingress is closed")
|
||||
if self._consumer_id is not None and self._consumer_id != consumer_id:
|
||||
raise RuntimeError("live perception ingress already has a consumer")
|
||||
self._consumer_id = consumer_id
|
||||
|
||||
def close_consumer(self, consumer_id: str) -> None:
|
||||
with self._condition:
|
||||
if self._consumer_id == consumer_id:
|
||||
self._consumer_id = None
|
||||
self._condition.notify_all()
|
||||
|
||||
def take_next(
|
||||
self,
|
||||
consumer_id: str,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
) -> LiveIngressEvent | None:
|
||||
with self._condition:
|
||||
if self._consumer_id != consumer_id:
|
||||
raise RuntimeError("live perception consumer lease is not active")
|
||||
ready = self._condition.wait_for(
|
||||
lambda: any(queue.items for queue in self._queues.values()) or self._closed,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not ready:
|
||||
return None
|
||||
candidates = [
|
||||
(queue.items[0].ingress_sequence, modality, queue)
|
||||
for modality, queue in self._queues.items()
|
||||
if queue.items
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
_, _, selected = min(candidates, key=lambda item: item[0])
|
||||
selected.consumed += 1
|
||||
return selected.items.popleft()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._condition:
|
||||
self._closed = True
|
||||
self._active = False
|
||||
self._condition.notify_all()
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
with self._condition:
|
||||
return {
|
||||
"schema_version": LIVE_INGRESS_SCHEMA,
|
||||
"mode": "shadow-diagnostic-only",
|
||||
"active": self._active,
|
||||
"session_id": self._session_id,
|
||||
"consumer_connected": self._consumer_id is not None,
|
||||
"commands_enabled": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"closed": self._closed,
|
||||
"queues": {
|
||||
modality: {
|
||||
"capacity": queue.capacity,
|
||||
"depth": len(queue.items),
|
||||
"maximum_depth": queue.maximum_depth,
|
||||
"published": queue.published,
|
||||
"consumed": queue.consumed,
|
||||
"dropped_overflow": queue.dropped_overflow,
|
||||
"rejected_oversize": queue.rejected_oversize,
|
||||
}
|
||||
for modality, queue in self._queues.items()
|
||||
},
|
||||
}
|
||||
|
||||
def _publish_locked(
|
||||
self,
|
||||
*,
|
||||
modality: LiveIngressModality,
|
||||
source_id: str,
|
||||
source_sequence: int,
|
||||
captured_at_epoch_ns: int,
|
||||
received_monotonic_ns: int,
|
||||
payload: bytes,
|
||||
) -> bool:
|
||||
queue = self._queues[modality]
|
||||
if len(payload) > self._MAX_PAYLOAD_BYTES[modality]:
|
||||
queue.rejected_oversize += 1
|
||||
return False
|
||||
session_id = self._session_id
|
||||
if session_id is None:
|
||||
return False
|
||||
self._ingress_sequence += 1
|
||||
event = LiveIngressEvent(
|
||||
ingress_sequence=self._ingress_sequence,
|
||||
session_id=session_id,
|
||||
modality=modality,
|
||||
source_id=source_id,
|
||||
source_sequence=source_sequence,
|
||||
captured_at_epoch_ns=captured_at_epoch_ns,
|
||||
received_monotonic_ns=received_monotonic_ns,
|
||||
payload=bytes(payload),
|
||||
)
|
||||
if len(queue.items) == queue.capacity:
|
||||
queue.items.popleft()
|
||||
queue.dropped_overflow += 1
|
||||
queue.items.append(event)
|
||||
queue.published += 1
|
||||
queue.maximum_depth = max(queue.maximum_depth, len(queue.items))
|
||||
self._condition.notify_all()
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QueueSnapshot:
|
||||
capacity: int
|
||||
depth: int
|
||||
maximum_depth: int
|
||||
published: int
|
||||
consumed: int
|
||||
dropped_overflow: int
|
||||
dropped_superseded: int
|
||||
closed: bool
|
||||
|
||||
@property
|
||||
def dropped_total(self) -> int:
|
||||
return self.dropped_overflow + self.dropped_superseded
|
||||
|
||||
|
||||
class LatestWinsQueue[T]:
|
||||
"""A bounded derived-data queue that never lets old preview work accumulate."""
|
||||
|
||||
def __init__(self, capacity: int) -> None:
|
||||
if capacity < 1:
|
||||
raise ValueError("latest-wins queue capacity must be positive")
|
||||
self._capacity = capacity
|
||||
self._items: deque[T] = deque()
|
||||
self._condition = threading.Condition()
|
||||
self._maximum_depth = 0
|
||||
self._published = 0
|
||||
self._consumed = 0
|
||||
self._dropped_overflow = 0
|
||||
self._dropped_superseded = 0
|
||||
self._closed = False
|
||||
|
||||
def publish(self, item: T) -> None:
|
||||
with self._condition:
|
||||
if self._closed:
|
||||
raise RuntimeError("cannot publish to a closed latest-wins queue")
|
||||
if len(self._items) == self._capacity:
|
||||
self._items.popleft()
|
||||
self._dropped_overflow += 1
|
||||
self._items.append(item)
|
||||
self._published += 1
|
||||
self._maximum_depth = max(self._maximum_depth, len(self._items))
|
||||
self._condition.notify()
|
||||
|
||||
def take_next(self, timeout: float | None = None) -> T | None:
|
||||
with self._condition:
|
||||
ready = self._condition.wait_for(
|
||||
lambda: bool(self._items) or self._closed,
|
||||
timeout=timeout,
|
||||
)
|
||||
if not ready or not self._items:
|
||||
return None
|
||||
item = self._items.popleft()
|
||||
self._consumed += 1
|
||||
return item
|
||||
|
||||
def close(self) -> None:
|
||||
with self._condition:
|
||||
self._closed = True
|
||||
self._condition.notify_all()
|
||||
|
||||
def snapshot(self) -> QueueSnapshot:
|
||||
with self._condition:
|
||||
return QueueSnapshot(
|
||||
capacity=self._capacity,
|
||||
depth=len(self._items),
|
||||
maximum_depth=self._maximum_depth,
|
||||
published=self._published,
|
||||
consumed=self._consumed,
|
||||
dropped_overflow=self._dropped_overflow,
|
||||
dropped_superseded=self._dropped_superseded,
|
||||
closed=self._closed,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveSensorBinding:
|
||||
"""One bounded camera→LiDAR→pose match for live diagnostic fusion."""
|
||||
|
||||
state: Literal[
|
||||
"fused-ready",
|
||||
"lidar-unavailable",
|
||||
"lidar-camera-delta-exceeded",
|
||||
"pose-unavailable",
|
||||
"pose-point-delta-exceeded",
|
||||
]
|
||||
point_cloud: DecodedPointCloudView | None
|
||||
pose: DecodedPoseView | None
|
||||
lidar_camera_delta_ms: float | None
|
||||
pose_point_delta_ms: float | None
|
||||
|
||||
|
||||
class LiveSensorSynchronizer:
|
||||
"""Keep a small arrival-time window and bind sensors without back-pressure.
|
||||
|
||||
The synchronizer deliberately uses the already-recorded host arrival clock
|
||||
carried by the shadow wire contract. It does not claim hardware-clock
|
||||
synchronization. A short wait budget lets a LiDAR or pose event that is
|
||||
already in flight reach the receiver while keeping the detector/world-state
|
||||
latency bounded.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
maximum_lidar_camera_delta_ms: float,
|
||||
maximum_pose_point_delta_ms: float,
|
||||
capacity_per_modality: int = 32,
|
||||
retention_seconds: float = 3.0,
|
||||
) -> None:
|
||||
if (
|
||||
maximum_lidar_camera_delta_ms <= 0
|
||||
or maximum_pose_point_delta_ms <= 0
|
||||
or capacity_per_modality < 2
|
||||
or retention_seconds <= 0
|
||||
):
|
||||
raise ValueError("live sensor synchronizer bounds are invalid")
|
||||
self._maximum_lidar_camera_delta_ns = round(
|
||||
maximum_lidar_camera_delta_ms * 1_000_000
|
||||
)
|
||||
self._maximum_pose_point_delta_ns = round(maximum_pose_point_delta_ms * 1_000_000)
|
||||
self._capacity = capacity_per_modality
|
||||
self._retention_ns = round(retention_seconds * 1_000_000_000)
|
||||
self._condition = threading.Condition()
|
||||
self._points: deque[DecodedPointCloudView] = deque()
|
||||
self._poses: deque[DecodedPoseView] = deque()
|
||||
self._published_points = 0
|
||||
self._published_poses = 0
|
||||
self._evicted_points = 0
|
||||
self._evicted_poses = 0
|
||||
self._maximum_point_depth = 0
|
||||
self._maximum_pose_depth = 0
|
||||
|
||||
def publish_point_cloud(self, value: DecodedPointCloudView) -> None:
|
||||
with self._condition:
|
||||
self._points.append(value)
|
||||
self._published_points += 1
|
||||
self._evicted_points += self._prune(self._points)
|
||||
self._maximum_point_depth = max(self._maximum_point_depth, len(self._points))
|
||||
self._condition.notify_all()
|
||||
|
||||
def publish_pose(self, value: DecodedPoseView) -> None:
|
||||
with self._condition:
|
||||
self._poses.append(value)
|
||||
self._published_poses += 1
|
||||
self._evicted_poses += self._prune(self._poses)
|
||||
self._maximum_pose_depth = max(self._maximum_pose_depth, len(self._poses))
|
||||
self._condition.notify_all()
|
||||
|
||||
def bind_camera(
|
||||
self,
|
||||
captured_at_epoch_ns: int,
|
||||
*,
|
||||
wait_seconds: float = 0.0,
|
||||
) -> LiveSensorBinding:
|
||||
if captured_at_epoch_ns < 0 or wait_seconds < 0 or not math.isfinite(wait_seconds):
|
||||
raise ValueError("camera synchronization input is invalid")
|
||||
deadline = time.monotonic() + wait_seconds
|
||||
with self._condition:
|
||||
while True:
|
||||
binding = self._binding(captured_at_epoch_ns)
|
||||
if binding.state == "fused-ready" or wait_seconds == 0:
|
||||
return binding
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
return binding
|
||||
self._condition.wait(timeout=remaining)
|
||||
|
||||
def snapshot(self) -> dict[str, int | float]:
|
||||
with self._condition:
|
||||
return {
|
||||
"capacity_per_modality": self._capacity,
|
||||
"retention_seconds": self._retention_ns / 1_000_000_000,
|
||||
"point_depth": len(self._points),
|
||||
"pose_depth": len(self._poses),
|
||||
"maximum_point_depth": self._maximum_point_depth,
|
||||
"maximum_pose_depth": self._maximum_pose_depth,
|
||||
"published_points": self._published_points,
|
||||
"published_poses": self._published_poses,
|
||||
"evicted_points": self._evicted_points,
|
||||
"evicted_poses": self._evicted_poses,
|
||||
}
|
||||
|
||||
def _binding(self, captured_at_epoch_ns: int) -> LiveSensorBinding:
|
||||
if not self._points:
|
||||
return LiveSensorBinding("lidar-unavailable", None, None, None, None)
|
||||
point = min(
|
||||
self._points,
|
||||
key=lambda value: abs(value.context.captured_at_epoch_ns - captured_at_epoch_ns),
|
||||
)
|
||||
lidar_delta_ns = point.context.captured_at_epoch_ns - captured_at_epoch_ns
|
||||
lidar_delta_ms = lidar_delta_ns / 1_000_000
|
||||
if abs(lidar_delta_ns) > self._maximum_lidar_camera_delta_ns:
|
||||
return LiveSensorBinding(
|
||||
"lidar-camera-delta-exceeded",
|
||||
point,
|
||||
None,
|
||||
lidar_delta_ms,
|
||||
None,
|
||||
)
|
||||
if not self._poses:
|
||||
return LiveSensorBinding(
|
||||
"pose-unavailable",
|
||||
point,
|
||||
None,
|
||||
lidar_delta_ms,
|
||||
None,
|
||||
)
|
||||
pose = min(
|
||||
self._poses,
|
||||
key=lambda value: abs(
|
||||
value.context.captured_at_epoch_ns - point.context.captured_at_epoch_ns
|
||||
),
|
||||
)
|
||||
pose_delta_ns = pose.context.captured_at_epoch_ns - point.context.captured_at_epoch_ns
|
||||
pose_delta_ms = pose_delta_ns / 1_000_000
|
||||
if abs(pose_delta_ns) > self._maximum_pose_point_delta_ns:
|
||||
return LiveSensorBinding(
|
||||
"pose-point-delta-exceeded",
|
||||
point,
|
||||
pose,
|
||||
lidar_delta_ms,
|
||||
pose_delta_ms,
|
||||
)
|
||||
return LiveSensorBinding(
|
||||
"fused-ready",
|
||||
point,
|
||||
pose,
|
||||
lidar_delta_ms,
|
||||
pose_delta_ms,
|
||||
)
|
||||
|
||||
def _prune(self, values: deque[DecodedPointCloudView] | deque[DecodedPoseView]) -> int:
|
||||
removed = 0
|
||||
newest = values[-1].context.captured_at_epoch_ns
|
||||
oldest_allowed = newest - self._retention_ns
|
||||
while values and (
|
||||
len(values) > self._capacity
|
||||
or values[0].context.captured_at_epoch_ns < oldest_allowed
|
||||
):
|
||||
values.popleft()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def classify_health(
|
||||
*,
|
||||
source_available: bool,
|
||||
fusion_state: str,
|
||||
result_age_ms: float,
|
||||
stale_after_ms: float,
|
||||
unavailable_after_ms: float,
|
||||
) -> tuple[HealthState, tuple[str, ...]]:
|
||||
"""Classify freshness separately from whether depth was available."""
|
||||
|
||||
if stale_after_ms <= 0 or unavailable_after_ms <= stale_after_ms:
|
||||
raise ValueError("health thresholds are invalid")
|
||||
if not source_available or result_age_ms >= unavailable_after_ms:
|
||||
return "unavailable", ("source-unavailable",)
|
||||
if result_age_ms >= stale_after_ms:
|
||||
return "stale", ("result-age-exceeded",)
|
||||
if fusion_state != "fused":
|
||||
return "degraded", (fusion_state,)
|
||||
return "healthy", ()
|
||||
|
||||
|
||||
class WorldStateProjector:
|
||||
"""Project accepted E6 observations into a control-facing, timestamped state."""
|
||||
|
||||
def __init__(self, *, velocity_history_limit_s: float = 1.0) -> None:
|
||||
if velocity_history_limit_s <= 0:
|
||||
raise ValueError("velocity history limit must be positive")
|
||||
self._velocity_history_limit_s = velocity_history_limit_s
|
||||
self._track_history: dict[
|
||||
int, deque[tuple[float, tuple[float, float, float]]]
|
||||
] = {}
|
||||
|
||||
def project(
|
||||
self,
|
||||
*,
|
||||
frame: Mapping[str, Any],
|
||||
lidar_positions: Mapping[int, Sequence[float]],
|
||||
clearance: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
session_seconds = float(frame["session_seconds"])
|
||||
objects: list[dict[str, Any]] = []
|
||||
for raw in frame.get("objects", []):
|
||||
if not str(raw.get("cuboid_status", "")).startswith("accepted-"):
|
||||
continue
|
||||
track_id = int(raw["track_id"])
|
||||
center_map = _vector3(raw["cuboid_center_map"], "cuboid center")
|
||||
half_size = _vector3(raw["cuboid_half_size"], "cuboid half-size")
|
||||
quaternion = _vector4(raw["cuboid_quaternion_xyzw"], "cuboid quaternion")
|
||||
velocity, velocity_status, velocity_residual = self._velocity(
|
||||
track_id, session_seconds, center_map
|
||||
)
|
||||
speed = None
|
||||
if velocity is not None:
|
||||
speed = math.sqrt(sum(component * component for component in velocity))
|
||||
lidar = lidar_positions.get(track_id)
|
||||
objects.append(
|
||||
{
|
||||
"track_id": track_id,
|
||||
"class": str(raw["association_group"]),
|
||||
"detector_label": str(raw["label"]),
|
||||
"confidence": float(raw["score"]),
|
||||
"position_map_m": list(center_map),
|
||||
"position_lidar_m": (
|
||||
None if lidar is None else [float(value) for value in lidar]
|
||||
),
|
||||
"orientation_map_xyzw": list(quaternion),
|
||||
"size_m": [2.0 * value for value in half_size],
|
||||
"range_m": float(raw["distance_smoothed_m"]),
|
||||
"velocity_map_mps": None if velocity is None else list(velocity),
|
||||
"speed_mps": speed,
|
||||
"velocity_status": velocity_status,
|
||||
"velocity_residual_m": velocity_residual,
|
||||
"support_points": int(raw["clustered_points"]),
|
||||
"geometry": "point-supported-visible-surface-envelope",
|
||||
}
|
||||
)
|
||||
self._prune(session_seconds)
|
||||
return {
|
||||
"schema_version": WORLD_STATE_SCHEMA,
|
||||
"frame_index": int(frame["frame_index"]),
|
||||
"source_frame_index": int(frame["source_frame_index"]),
|
||||
"session_seconds": session_seconds,
|
||||
"coordinate_frames": {
|
||||
"world": "k1-map",
|
||||
"sensor_relative": "k1-lidar",
|
||||
"vehicle_body": "unavailable-no-rig-to-vehicle-transform",
|
||||
},
|
||||
"fusion_state": str(frame["state"]),
|
||||
"objects": objects,
|
||||
"object_count": len(objects),
|
||||
"clearance": dict(clearance),
|
||||
}
|
||||
|
||||
def _velocity(
|
||||
self,
|
||||
track_id: int,
|
||||
session_seconds: float,
|
||||
center_map: tuple[float, float, float],
|
||||
) -> tuple[tuple[float, float, float] | None, str, float | None]:
|
||||
history = self._track_history.setdefault(track_id, deque(maxlen=32))
|
||||
if history and session_seconds <= history[-1][0]:
|
||||
return None, "unavailable-nonmonotonic-time", None
|
||||
history.append((session_seconds, center_map))
|
||||
oldest = session_seconds - self._velocity_history_limit_s
|
||||
while history and history[0][0] < oldest:
|
||||
history.popleft()
|
||||
if len(history) < 4 or history[-1][0] - history[0][0] < 0.4:
|
||||
return None, "unavailable-insufficient-history", None
|
||||
slopes: list[tuple[float, float, float]] = []
|
||||
values = list(history)
|
||||
for left, (left_time, left_center) in enumerate(values):
|
||||
for right_time, right_center in values[left + 1 :]:
|
||||
delta = right_time - left_time
|
||||
if delta < 0.2:
|
||||
continue
|
||||
slopes.append(
|
||||
(
|
||||
(right_center[0] - left_center[0]) / delta,
|
||||
(right_center[1] - left_center[1]) / delta,
|
||||
(right_center[2] - left_center[2]) / delta,
|
||||
)
|
||||
)
|
||||
if not slopes:
|
||||
return None, "unavailable-insufficient-baseline", None
|
||||
velocity = (
|
||||
statistics.median(item[0] for item in slopes),
|
||||
statistics.median(item[1] for item in slopes),
|
||||
statistics.median(item[2] for item in slopes),
|
||||
)
|
||||
speed = math.sqrt(sum(component * component for component in velocity))
|
||||
latest_time, latest_center = values[-1]
|
||||
residuals = []
|
||||
for observed_time, observed_center in values:
|
||||
predicted = tuple(
|
||||
latest - component * (latest_time - observed_time)
|
||||
for latest, component in zip(latest_center, velocity, strict=True)
|
||||
)
|
||||
residuals.append(
|
||||
math.sqrt(
|
||||
sum(
|
||||
(observed - expected) ** 2
|
||||
for observed, expected in zip(
|
||||
observed_center, predicted, strict=True
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
residual = statistics.median(residuals)
|
||||
if speed > 20.0:
|
||||
return None, "rejected-speed-bound", residual
|
||||
if residual > 0.75:
|
||||
return None, "rejected-position-residual", residual
|
||||
return velocity, "diagnostic-robust-history", residual
|
||||
|
||||
def _prune(self, session_seconds: float) -> None:
|
||||
oldest = session_seconds - self._velocity_history_limit_s
|
||||
expired = [
|
||||
track_id
|
||||
for track_id, history in self._track_history.items()
|
||||
if not history or history[-1][0] < oldest
|
||||
]
|
||||
for track_id in expired:
|
||||
del self._track_history[track_id]
|
||||
|
||||
|
||||
def wait_until(deadline: float) -> float:
|
||||
"""Wait for a replay deadline and return non-negative scheduling lag seconds."""
|
||||
|
||||
remaining = deadline - time.perf_counter()
|
||||
if remaining > 0:
|
||||
time.sleep(remaining)
|
||||
return max(0.0, time.perf_counter() - deadline)
|
||||
|
||||
|
||||
def _vector3(value: Sequence[Any], label: str) -> tuple[float, float, float]:
|
||||
if len(value) != 3:
|
||||
raise ValueError(f"{label} must contain three values")
|
||||
return float(value[0]), float(value[1]), float(value[2])
|
||||
|
||||
|
||||
def _vector4(value: Sequence[Any], label: str) -> tuple[float, float, float, float]:
|
||||
if len(value) != 4:
|
||||
raise ValueError(f"{label} must contain four values")
|
||||
return float(value[0]), float(value[1]), float(value[2]), float(value[3])
|
||||
@@ -0,0 +1,368 @@
|
||||
"""Validation for immutable LAB E7 replay-as-live qualification results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .live_perception import TELEMETRY_SCHEMA, WORLD_STATE_SCHEMA
|
||||
from .tracked_fusion_qualification import (
|
||||
TrackedFusionQualificationResult,
|
||||
validate_tracked_fusion_qualification_result,
|
||||
)
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e7-live-replay-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e7-live-replay-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e7-live-replay-run-report/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e7-live-replay-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveReplayQualificationResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
upstream: TrackedFusionQualificationResult
|
||||
frame_count: int
|
||||
accepted: bool
|
||||
artifacts: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
def validate_live_replay_qualification_result(
|
||||
job_root: Path,
|
||||
tracking_root: Path,
|
||||
semantic_root: Path,
|
||||
e6_root: Path,
|
||||
result_root: Path,
|
||||
) -> LiveReplayQualificationResult:
|
||||
upstream = validate_tracked_fusion_qualification_result(
|
||||
job_root,
|
||||
tracking_root,
|
||||
semantic_root,
|
||||
e6_root,
|
||||
)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("live replay result root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e7-live-replay-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("e6_result_id") != upstream.result_id
|
||||
or identity.get("job_id") != upstream.job.job_id
|
||||
or identity.get("input_sha256") != upstream.job.input_sha256
|
||||
or identity.get("session_id") != upstream.job.session_id
|
||||
or identity.get("source_id") != upstream.job.source_id
|
||||
or identity.get("calibration_sha256") != upstream.semantic.calibration_sha256
|
||||
or identity.get("camera_slot") != upstream.semantic.calibration_slot
|
||||
or not _valid_sha256(identity.get("producer_sha256"))
|
||||
or not _valid_sha256(identity.get("runtime_contract_sha256"))
|
||||
or not _valid_sha256(identity.get("result_validator_sha256"))
|
||||
or not _valid_sha256(identity.get("e6_adapter_sha256"))
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope") != "replay-qualification-only"
|
||||
or result.get("acceptance_state") not in {"accepted", "rejected"}
|
||||
):
|
||||
raise SessionIntegrityError("live replay identity is inconsistent")
|
||||
selection = identity.get("selection")
|
||||
configuration = identity.get("configuration")
|
||||
if not isinstance(selection, dict) or not isinstance(configuration, dict):
|
||||
raise SessionIntegrityError("live replay configuration is missing")
|
||||
start = selection.get("start_frame_index")
|
||||
count = selection.get("frame_count")
|
||||
if (
|
||||
not isinstance(start, int)
|
||||
or not isinstance(count, int)
|
||||
or start < 0
|
||||
or count < 1
|
||||
or start + count > upstream.frame_count
|
||||
or configuration.get("selection") != selection
|
||||
):
|
||||
raise SessionIntegrityError("live replay selection is invalid")
|
||||
metrics = result.get("metrics")
|
||||
if not isinstance(metrics, dict):
|
||||
raise SessionIntegrityError("live replay metrics are invalid")
|
||||
processed = metrics.get("frames_processed")
|
||||
dropped = metrics.get("frames_dropped")
|
||||
queue = metrics.get("queue")
|
||||
health = metrics.get("health_counts")
|
||||
if (
|
||||
metrics.get("frames_attempted") != count
|
||||
or not isinstance(processed, int)
|
||||
or not isinstance(dropped, int)
|
||||
or processed < 1
|
||||
or dropped < 0
|
||||
or processed + dropped != count
|
||||
or not isinstance(queue, dict)
|
||||
or queue.get("published") != count
|
||||
or queue.get("consumed") != processed
|
||||
or queue.get("final_depth") != 0
|
||||
or queue.get("dropped_overflow", 0) + queue.get("dropped_superseded", 0)
|
||||
!= dropped
|
||||
or not isinstance(health, dict)
|
||||
or sum(health.values()) != processed
|
||||
):
|
||||
raise SessionIntegrityError("live replay accounting is inconsistent")
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
world_indices = _validate_world_rows(
|
||||
_artifact_path(artifacts, "live-world-state", root),
|
||||
root,
|
||||
expected_count=processed,
|
||||
selection_start=start,
|
||||
selection_count=count,
|
||||
)
|
||||
_validate_telemetry_rows(
|
||||
_artifact_path(artifacts, "live-telemetry", root),
|
||||
root,
|
||||
expected_indices=world_indices,
|
||||
)
|
||||
report = _read_object(_artifact_path(artifacts, "live-run-report", root), root)
|
||||
acceptance = report.get("acceptance")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("identity") != identity
|
||||
or report.get("metrics") != metrics
|
||||
or not isinstance(acceptance, dict)
|
||||
or not isinstance(acceptance.get("accepted"), bool)
|
||||
or acceptance.get("navigation_or_safety_accepted") is not False
|
||||
or (result.get("acceptance_state") == "accepted")
|
||||
!= acceptance.get("accepted")
|
||||
):
|
||||
raise SessionIntegrityError("live replay report is inconsistent")
|
||||
rrd = _artifact_path(artifacts, "live-rerun", root)
|
||||
with rrd.open("rb") as stream:
|
||||
if stream.read(4) != b"RRF2":
|
||||
raise SessionIntegrityError("live replay Rerun stream is invalid")
|
||||
return LiveReplayQualificationResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
upstream=upstream,
|
||||
frame_count=processed,
|
||||
accepted=bool(acceptance["accepted"]),
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifacts(root: Path, raw: object) -> tuple[dict[str, Any], ...]:
|
||||
expected = {
|
||||
"live-world-state": (
|
||||
"world-state.jsonl",
|
||||
"application/x-ndjson",
|
||||
WORLD_STATE_SCHEMA,
|
||||
),
|
||||
"live-telemetry": (
|
||||
"telemetry.jsonl",
|
||||
"application/x-ndjson",
|
||||
TELEMETRY_SCHEMA,
|
||||
),
|
||||
"live-rerun": ("world-state.rrd", "application/vnd.rerun.rrd", None),
|
||||
"live-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("live replay artifact set is invalid")
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("live replay artifact descriptor is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("live replay artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
name, media_type, schema = expected[kind]
|
||||
path = root / name
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if (
|
||||
value.get("path") != name
|
||||
or value.get("media_type") != media_type
|
||||
or value.get("schema_version") != schema
|
||||
or value.get("byte_length") != metadata.st_size
|
||||
or not isinstance(value.get("sha256"), str)
|
||||
or _SHA256.fullmatch(str(value["sha256"])) is None
|
||||
or _sha256(path) != value["sha256"]
|
||||
):
|
||||
raise SessionIntegrityError("live replay artifact identity changed")
|
||||
artifacts.append(value)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _validate_world_rows(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
selection_start: int,
|
||||
selection_count: int,
|
||||
) -> tuple[int, ...]:
|
||||
indices: list[int] = []
|
||||
previous_time: float | None = None
|
||||
_confined_regular_file(path, root)
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
if len(line.encode()) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("live replay world-state line is oversized")
|
||||
value = json.loads(line)
|
||||
frame_index = value.get("frame_index") if isinstance(value, dict) else None
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
objects = value.get("objects") if isinstance(value, dict) else None
|
||||
delivery = value.get("delivery") if isinstance(value, dict) else None
|
||||
coordinate_frames = (
|
||||
value.get("coordinate_frames") if isinstance(value, dict) else None
|
||||
)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != WORLD_STATE_SCHEMA
|
||||
or not isinstance(frame_index, int)
|
||||
or not selection_start <= frame_index < selection_start + selection_count
|
||||
or (indices and frame_index <= indices[-1])
|
||||
or not isinstance(session_seconds, int | float)
|
||||
or (previous_time is not None and float(session_seconds) <= previous_time)
|
||||
or not isinstance(objects, list)
|
||||
or value.get("object_count") != len(objects)
|
||||
or not isinstance(delivery, dict)
|
||||
or delivery.get("health")
|
||||
not in {"healthy", "degraded", "stale", "unavailable"}
|
||||
or not isinstance(delivery.get("result_age_ms"), int | float)
|
||||
or not isinstance(coordinate_frames, dict)
|
||||
or coordinate_frames.get("sensor_relative") != "k1-lidar"
|
||||
):
|
||||
raise SessionIntegrityError("live replay world-state row is invalid")
|
||||
for item in objects:
|
||||
if not _valid_object(item):
|
||||
raise SessionIntegrityError("live replay world object is invalid")
|
||||
indices.append(frame_index)
|
||||
previous_time = float(session_seconds)
|
||||
if len(indices) != expected_count:
|
||||
raise SessionIntegrityError("live replay world-state count changed")
|
||||
return tuple(indices)
|
||||
|
||||
|
||||
def _valid_object(value: object) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
position_map = value.get("position_map_m")
|
||||
position_lidar = value.get("position_lidar_m")
|
||||
size = value.get("size_m")
|
||||
velocity = value.get("velocity_map_mps")
|
||||
return (
|
||||
isinstance(value.get("track_id"), int)
|
||||
and isinstance(value.get("range_m"), int | float)
|
||||
and float(value["range_m"]) >= 0
|
||||
and _finite_vector(position_map, 3)
|
||||
and (position_lidar is None or _finite_vector(position_lidar, 3))
|
||||
and _finite_vector(size, 3)
|
||||
and (velocity is None or _finite_vector(velocity, 3))
|
||||
and isinstance(value.get("velocity_status"), str)
|
||||
and value.get("geometry") == "point-supported-visible-surface-envelope"
|
||||
)
|
||||
|
||||
|
||||
def _finite_vector(value: object, length: int) -> bool:
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == length
|
||||
and all(isinstance(item, int | float) and math.isfinite(float(item)) for item in value)
|
||||
)
|
||||
|
||||
|
||||
def _valid_sha256(value: object) -> bool:
|
||||
return isinstance(value, str) and _SHA256.fullmatch(value) is not None
|
||||
|
||||
|
||||
def _validate_telemetry_rows(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_indices: tuple[int, ...],
|
||||
) -> None:
|
||||
indices: list[int] = []
|
||||
_confined_regular_file(path, root)
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
if len(line.encode()) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("live replay telemetry line is oversized")
|
||||
value = json.loads(line)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != TELEMETRY_SCHEMA
|
||||
or not isinstance(value.get("frame_index"), int)
|
||||
or not isinstance(value.get("end_to_end_latency_ms"), int | float)
|
||||
or float(value["end_to_end_latency_ms"]) < 0
|
||||
or value.get("health")
|
||||
not in {"healthy", "degraded", "stale", "unavailable"}
|
||||
):
|
||||
raise SessionIntegrityError("live replay telemetry row is invalid")
|
||||
indices.append(int(value["frame_index"]))
|
||||
if tuple(indices) != expected_indices:
|
||||
raise SessionIntegrityError("live replay telemetry binding changed")
|
||||
|
||||
|
||||
def _artifact_path(
|
||||
artifacts: tuple[dict[str, Any], ...], kind: str, root: Path
|
||||
) -> Path:
|
||||
matches = [value for value in artifacts if value.get("kind") == kind]
|
||||
if len(matches) != 1 or not isinstance(matches[0].get("path"), str):
|
||||
raise SessionIntegrityError("live replay artifact lookup is invalid")
|
||||
return root / str(matches[0]["path"])
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if metadata.st_size > MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("live replay JSON is oversized")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("live replay JSON is unreadable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("live replay JSON root must be an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
resolved = path.resolve(strict=True)
|
||||
if not resolved.is_relative_to(root) or not resolved.is_file():
|
||||
raise SessionIntegrityError("live replay artifact escapes its result root")
|
||||
metadata = resolved.stat()
|
||||
if stat.S_ISLNK(path.lstat().st_mode) or not stat.S_ISREG(metadata.st_mode):
|
||||
raise SessionIntegrityError("live replay artifact must be a regular file")
|
||||
return metadata
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,534 @@
|
||||
"""Validation for immutable LAB E9 multirate perception results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e9-multirate-perception-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e9-multirate-perception-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e9-multirate-perception-report/v1"
|
||||
PROFILE_SCHEMA = "missioncore.e9-multirate-perception-profile/v1"
|
||||
DETECTOR_FRAME_SCHEMA = "missioncore.e9-multirate-detector-frame/v1"
|
||||
SEMANTIC_FRAME_SCHEMA = "missioncore.e9-multirate-semantic-frame/v1"
|
||||
MERGED_FRAME_SCHEMA = "missioncore.e9-multirate-merged-frame/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 2 * 1024 * 1024
|
||||
MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e9-multirate-perception-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MultiratePerceptionArtifact:
|
||||
kind: str
|
||||
path: Path
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str
|
||||
schema_version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MultiratePerceptionQualificationResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
expected_detector_frames: int
|
||||
processed_detector_frames: int
|
||||
processed_semantic_frames: int
|
||||
accepted: bool
|
||||
artifacts: tuple[MultiratePerceptionArtifact, ...]
|
||||
|
||||
def artifact(self, kind: str) -> MultiratePerceptionArtifact:
|
||||
matches = tuple(item for item in self.artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"multirate artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def validate_multirate_perception_qualification_result(
|
||||
job_root: Path,
|
||||
result_root: Path,
|
||||
) -> MultiratePerceptionQualificationResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("multirate result root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e9-multirate-perception-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("session_id") != job.session_id
|
||||
or identity.get("source_id") != job.source_id
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope") != "recorded-multirate-qualification-only"
|
||||
or result.get("acceptance_state") not in {"accepted", "rejected"}
|
||||
):
|
||||
raise SessionIntegrityError("multirate identity is inconsistent")
|
||||
selection = identity.get("selection")
|
||||
configuration = identity.get("configuration")
|
||||
if not isinstance(selection, dict) or not isinstance(configuration, dict):
|
||||
raise SessionIntegrityError("multirate selection is unavailable")
|
||||
expected = selection.get("frame_count")
|
||||
source_start = selection.get("source_start_frame_index")
|
||||
source_end = selection.get("source_end_frame_index")
|
||||
timeline_start = selection.get("timeline_start_seconds")
|
||||
timeline_end = selection.get("timeline_end_seconds")
|
||||
profile = configuration.get("profile")
|
||||
if (
|
||||
not isinstance(expected, int)
|
||||
or isinstance(expected, bool)
|
||||
or expected < 2
|
||||
or not isinstance(source_start, int)
|
||||
or isinstance(source_start, bool)
|
||||
or not isinstance(source_end, int)
|
||||
or isinstance(source_end, bool)
|
||||
or source_start < 0
|
||||
or source_end - source_start + 1 != expected
|
||||
or source_end >= job.segment_count
|
||||
or not _finite_number(timeline_start)
|
||||
or not _finite_number(timeline_end)
|
||||
or float(timeline_start) < job.timeline_start_seconds
|
||||
or float(timeline_end) > job.timeline_end_seconds
|
||||
or float(timeline_end) <= float(timeline_start)
|
||||
or not isinstance(profile, dict)
|
||||
or profile.get("schema_version") != PROFILE_SCHEMA
|
||||
or not isinstance(profile.get("replay"), dict)
|
||||
):
|
||||
raise SessionIntegrityError("multirate selection is invalid")
|
||||
replay = profile["replay"]
|
||||
stride = replay.get("semantic_sample_every_frames")
|
||||
if (
|
||||
not isinstance(stride, int)
|
||||
or isinstance(stride, bool)
|
||||
or stride < 2
|
||||
or replay.get("detector_queue_capacity") not in range(1, 9)
|
||||
or replay.get("semantic_queue_capacity") not in range(1, 5)
|
||||
):
|
||||
raise SessionIntegrityError("multirate scheduling profile is invalid")
|
||||
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
report = _read_object(_artifact(artifacts, "multirate-run-report").path, root)
|
||||
metrics = report.get("metrics")
|
||||
acceptance = report.get("acceptance")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("identity") != identity
|
||||
or report.get("ground_truth") is not False
|
||||
or report.get("state") != result.get("acceptance_state")
|
||||
or not isinstance(metrics, dict)
|
||||
or not isinstance(acceptance, dict)
|
||||
or not isinstance(acceptance.get("accepted"), bool)
|
||||
or acceptance.get("navigation_or_safety_accepted") is not False
|
||||
or (result.get("acceptance_state") == "accepted") != acceptance["accepted"]
|
||||
):
|
||||
raise SessionIntegrityError("multirate report is inconsistent")
|
||||
detector = metrics.get("detector")
|
||||
semantic = metrics.get("semantic")
|
||||
if not isinstance(detector, dict) or not isinstance(semantic, dict):
|
||||
raise SessionIntegrityError("multirate metrics are unavailable")
|
||||
detector_processed, detector_dropped = _validate_queue_metrics(
|
||||
detector,
|
||||
expected=expected,
|
||||
capacity=int(replay["detector_queue_capacity"]),
|
||||
expected_key="frames_expected",
|
||||
)
|
||||
semantic_scheduled = ((expected - 1) // stride) + 1
|
||||
semantic_processed, _semantic_dropped = _validate_queue_metrics(
|
||||
semantic,
|
||||
expected=semantic_scheduled,
|
||||
capacity=int(replay["semantic_queue_capacity"]),
|
||||
expected_key="frames_scheduled",
|
||||
)
|
||||
if (
|
||||
result.get("frames_processed") != detector_processed
|
||||
or detector_processed + detector_dropped != expected
|
||||
or not isinstance(semantic.get("binding_status_counts"), dict)
|
||||
or sum(semantic["binding_status_counts"].values()) != detector_processed
|
||||
):
|
||||
raise SessionIntegrityError("multirate result accounting is inconsistent")
|
||||
|
||||
detector_indices = _validate_detector_rows(
|
||||
_artifact(artifacts, "multirate-detector-frames").path,
|
||||
root,
|
||||
expected_count=detector_processed,
|
||||
selection_count=expected,
|
||||
source_start=source_start,
|
||||
timeline_start=float(timeline_start),
|
||||
timeline_end=float(timeline_end),
|
||||
)
|
||||
semantic_indices = _validate_semantic_rows(
|
||||
_artifact(artifacts, "multirate-semantic-frames").path,
|
||||
root,
|
||||
expected_count=semantic_processed,
|
||||
selection_count=expected,
|
||||
source_start=source_start,
|
||||
stride=stride,
|
||||
timeline_start=float(timeline_start),
|
||||
timeline_end=float(timeline_end),
|
||||
)
|
||||
_validate_merged_rows(
|
||||
_artifact(artifacts, "multirate-merged-frames").path,
|
||||
root,
|
||||
expected_indices=detector_indices,
|
||||
source_start=source_start,
|
||||
semantic_indices=set(semantic_indices),
|
||||
)
|
||||
return MultiratePerceptionQualificationResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
expected_detector_frames=expected,
|
||||
processed_detector_frames=detector_processed,
|
||||
processed_semantic_frames=semantic_processed,
|
||||
accepted=bool(acceptance["accepted"]),
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_queue_metrics(
|
||||
metrics: dict[str, Any],
|
||||
*,
|
||||
expected: int,
|
||||
capacity: int,
|
||||
expected_key: str,
|
||||
) -> tuple[int, int]:
|
||||
processed = metrics.get("frames_processed")
|
||||
dropped = metrics.get("frames_dropped")
|
||||
queue = metrics.get("queue")
|
||||
if (
|
||||
metrics.get(expected_key) != expected
|
||||
or not isinstance(processed, int)
|
||||
or isinstance(processed, bool)
|
||||
or not 1 <= processed <= expected
|
||||
or not isinstance(dropped, int)
|
||||
or isinstance(dropped, bool)
|
||||
or dropped < 0
|
||||
or processed + dropped != expected
|
||||
or not isinstance(queue, dict)
|
||||
or queue.get("capacity") != capacity
|
||||
or queue.get("published") != expected
|
||||
or queue.get("consumed") != processed
|
||||
or queue.get("dropped_overflow") != dropped
|
||||
or queue.get("final_depth") != 0
|
||||
or queue.get("closed") is not True
|
||||
or not isinstance(queue.get("maximum_depth"), int)
|
||||
or not 0 <= queue["maximum_depth"] <= capacity
|
||||
):
|
||||
raise SessionIntegrityError("multirate queue accounting is inconsistent")
|
||||
return processed, dropped
|
||||
|
||||
|
||||
def _validate_detector_rows(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
selection_count: int,
|
||||
source_start: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
) -> tuple[int, ...]:
|
||||
rows = _read_jsonl(path, root)
|
||||
indices: list[int] = []
|
||||
previous_time = -math.inf
|
||||
for value in rows:
|
||||
index = value.get("frame_index")
|
||||
session_seconds = value.get("session_seconds")
|
||||
if (
|
||||
value.get("schema_version") != DETECTOR_FRAME_SCHEMA
|
||||
or not isinstance(index, int)
|
||||
or isinstance(index, bool)
|
||||
or not 0 <= index < selection_count
|
||||
or (indices and index <= indices[-1])
|
||||
or value.get("source_frame_index") != source_start + index
|
||||
or not _finite_number(session_seconds)
|
||||
or not timeline_start - 0.001 <= float(session_seconds) <= timeline_end + 0.001
|
||||
or float(session_seconds) <= previous_time
|
||||
or not _finite_number(value.get("result_age_ms"))
|
||||
or float(value["result_age_ms"]) < 0
|
||||
or not isinstance(value.get("detections"), list)
|
||||
or not isinstance(value.get("tracks"), list)
|
||||
):
|
||||
raise SessionIntegrityError("multirate detector row is invalid")
|
||||
indices.append(index)
|
||||
previous_time = float(session_seconds)
|
||||
if len(indices) != expected_count:
|
||||
raise SessionIntegrityError("multirate detector row count changed")
|
||||
return tuple(indices)
|
||||
|
||||
|
||||
def _validate_semantic_rows(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
selection_count: int,
|
||||
source_start: int,
|
||||
stride: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
) -> tuple[int, ...]:
|
||||
rows = _read_jsonl(path, root)
|
||||
indices: list[int] = []
|
||||
previous_time = -math.inf
|
||||
for value in rows:
|
||||
index = value.get("frame_index")
|
||||
session_seconds = value.get("session_seconds")
|
||||
class_pixels = value.get("class_pixels")
|
||||
fractions = value.get("class_fractions")
|
||||
if (
|
||||
value.get("schema_version") != SEMANTIC_FRAME_SCHEMA
|
||||
or not isinstance(index, int)
|
||||
or isinstance(index, bool)
|
||||
or not 0 <= index < selection_count
|
||||
or index % stride != 0
|
||||
or (indices and index <= indices[-1])
|
||||
or value.get("source_frame_index") != source_start + index
|
||||
or not _finite_number(session_seconds)
|
||||
or not timeline_start - 0.001 <= float(session_seconds) <= timeline_end + 0.001
|
||||
or float(session_seconds) <= previous_time
|
||||
or not _finite_number(value.get("completion_age_ms"))
|
||||
or float(value["completion_age_ms"]) < 0
|
||||
or not isinstance(value.get("mask_sha256"), str)
|
||||
or _SHA256.fullmatch(value["mask_sha256"]) is None
|
||||
or not isinstance(class_pixels, dict)
|
||||
or not isinstance(fractions, dict)
|
||||
or set(class_pixels) != set(fractions)
|
||||
or any(not isinstance(count, int) or count < 0 for count in class_pixels.values())
|
||||
or any(
|
||||
not _finite_number(fraction) or not 0 <= float(fraction) <= 1
|
||||
for fraction in fractions.values()
|
||||
)
|
||||
):
|
||||
raise SessionIntegrityError("multirate semantic row is invalid")
|
||||
indices.append(index)
|
||||
previous_time = float(session_seconds)
|
||||
if len(indices) != expected_count:
|
||||
raise SessionIntegrityError("multirate semantic row count changed")
|
||||
return tuple(indices)
|
||||
|
||||
|
||||
def _validate_merged_rows(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_indices: tuple[int, ...],
|
||||
source_start: int,
|
||||
semantic_indices: set[int],
|
||||
) -> None:
|
||||
rows = _read_jsonl(path, root)
|
||||
indices: list[int] = []
|
||||
for value in rows:
|
||||
index = value.get("frame_index")
|
||||
semantic = value.get("semantic")
|
||||
if (
|
||||
value.get("schema_version") != MERGED_FRAME_SCHEMA
|
||||
or not isinstance(index, int)
|
||||
or value.get("source_frame_index") != source_start + index
|
||||
or not isinstance(value.get("tracks"), list)
|
||||
or not _finite_number(value.get("detector_result_age_ms"))
|
||||
or not isinstance(semantic, dict)
|
||||
or semantic.get("status") not in {"fresh", "stale", "unavailable"}
|
||||
):
|
||||
raise SessionIntegrityError("multirate merged row is invalid")
|
||||
semantic_source = semantic.get("source_frame_index")
|
||||
if semantic["status"] == "unavailable":
|
||||
if any(
|
||||
semantic.get(key) is not None
|
||||
for key in (
|
||||
"source_frame_index",
|
||||
"source_age_ms",
|
||||
"completion_age_ms",
|
||||
"mask_sha256",
|
||||
)
|
||||
):
|
||||
raise SessionIntegrityError("unavailable semantic binding has invented data")
|
||||
elif (
|
||||
not isinstance(semantic_source, int)
|
||||
or semantic_source > source_start + index
|
||||
or semantic_source - source_start not in semantic_indices
|
||||
or not _finite_number(semantic.get("source_age_ms"))
|
||||
or float(semantic["source_age_ms"]) < 0
|
||||
or not _finite_number(semantic.get("completion_age_ms"))
|
||||
or not isinstance(semantic.get("mask_sha256"), str)
|
||||
or _SHA256.fullmatch(semantic["mask_sha256"]) is None
|
||||
):
|
||||
raise SessionIntegrityError("semantic binding is inconsistent")
|
||||
indices.append(index)
|
||||
if tuple(indices) != expected_indices:
|
||||
raise SessionIntegrityError("multirate merged binding changed")
|
||||
|
||||
|
||||
def _validate_artifacts(
|
||||
root: Path,
|
||||
raw: object,
|
||||
) -> tuple[MultiratePerceptionArtifact, ...]:
|
||||
expected = {
|
||||
"multirate-detector-frames": (
|
||||
"detector-frames.jsonl",
|
||||
"application/x-ndjson",
|
||||
DETECTOR_FRAME_SCHEMA,
|
||||
),
|
||||
"multirate-semantic-frames": (
|
||||
"semantic-frames.jsonl",
|
||||
"application/x-ndjson",
|
||||
SEMANTIC_FRAME_SCHEMA,
|
||||
),
|
||||
"multirate-merged-frames": (
|
||||
"merged-frames.jsonl",
|
||||
"application/x-ndjson",
|
||||
MERGED_FRAME_SCHEMA,
|
||||
),
|
||||
"worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None),
|
||||
"multirate-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("multirate artifact set is invalid")
|
||||
artifacts: list[MultiratePerceptionArtifact] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("multirate artifact descriptor is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("multirate artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
expected_path, expected_media_type, expected_schema = expected[kind]
|
||||
path = root / expected_path
|
||||
metadata = _confined_regular_file(path, root)
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
value.get("path") != expected_path
|
||||
or value.get("media_type") != expected_media_type
|
||||
or value.get("schema_version") != expected_schema
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or not 0 < byte_length <= MAX_SAFE_INTEGER
|
||||
or metadata.st_size != byte_length
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or _sha256_file(path) != digest
|
||||
):
|
||||
raise SessionIntegrityError("multirate artifact identity changed")
|
||||
artifacts.append(
|
||||
MultiratePerceptionArtifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
media_type=expected_media_type,
|
||||
byte_length=byte_length,
|
||||
sha256=digest,
|
||||
schema_version=expected_schema,
|
||||
)
|
||||
)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _artifact(
|
||||
artifacts: tuple[MultiratePerceptionArtifact, ...],
|
||||
kind: str,
|
||||
) -> MultiratePerceptionArtifact:
|
||||
matches = tuple(item for item in artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"multirate artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _read_jsonl(path: Path, root: Path) -> list[dict[str, Any]]:
|
||||
_confined_regular_file(path, root)
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
for line in stream:
|
||||
if not line or len(line) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("multirate JSONL line is outside bounds")
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("multirate JSONL row is invalid")
|
||||
rows.append(value)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("multirate JSONL is unavailable") from exc
|
||||
return rows
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("multirate JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("multirate JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("multirate JSON root is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("multirate artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("multirate artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("multirate identity is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _finite_number(value: object) -> TypeGuard[int | float]:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
@@ -0,0 +1,500 @@
|
||||
"""Validated full-epoch perception results and native recorded-video delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
RESULT_SCHEMA = "missioncore.recorded-perception-result/v2"
|
||||
IDENTITY_SCHEMA = "missioncore.recorded-perception-identity/v2"
|
||||
REPORT_SCHEMA = "missioncore.perception-run-report/v1"
|
||||
FRAME_SCHEMA = "missioncore.panoptic-frame/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_FRAME_LINE_BYTES = 1024 * 1024
|
||||
MAX_JOB_SCAN = 512
|
||||
MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^result-[a-f0-9]{64}$")
|
||||
_SAFE_RECORDING_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PerceptionArtifact:
|
||||
kind: str
|
||||
path: Path
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str
|
||||
schema_version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedPerceptionEpochResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
created_at_utc: str
|
||||
calibration_sha256: str
|
||||
calibration_slot: str
|
||||
artifacts: tuple[PerceptionArtifact, ...]
|
||||
|
||||
def artifact(self, kind: str) -> PerceptionArtifact:
|
||||
matches = tuple(item for item in self.artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"perception result artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedPerceptionVideo:
|
||||
result_id: str
|
||||
session_id: str
|
||||
source_id: str
|
||||
public_source_id: str
|
||||
label: str
|
||||
path: Path
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LatestResultCacheEntry:
|
||||
jobs_root_mtime_ns: int
|
||||
result_parent_mtime_ns: int
|
||||
file_fingerprint: tuple[tuple[str, int, int, int, int], ...]
|
||||
result: RecordedPerceptionEpochResult
|
||||
|
||||
|
||||
class RecordedPerceptionEpochStore:
|
||||
"""Discover immutable full-epoch results and expose their panoptic video."""
|
||||
|
||||
def __init__(self, *, jobs_root: Path, results_root: Path, ffprobe_path: Path) -> None:
|
||||
self.jobs_root = jobs_root.expanduser().absolute()
|
||||
self.results_root = results_root.expanduser().absolute()
|
||||
self.ffprobe_path = ffprobe_path.expanduser().resolve(strict=True)
|
||||
self._lock = threading.Lock()
|
||||
self._video_probe_cache: dict[tuple[str, int, int], tuple[float, str, int, int]] = {}
|
||||
self._latest_result_cache: dict[str, _LatestResultCacheEntry] = {}
|
||||
|
||||
def latest(self, session_id: str) -> RecordedPerceptionEpochResult | None:
|
||||
if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
|
||||
raise ValueError("observation session id is invalid")
|
||||
with self._lock:
|
||||
return self._latest_unlocked(session_id)
|
||||
|
||||
def video(
|
||||
self,
|
||||
session_id: str,
|
||||
result_id: str | None = None,
|
||||
) -> RecordedPerceptionVideo | None:
|
||||
if _SAFE_RECORDING_ID.fullmatch(session_id) is None:
|
||||
raise ValueError("observation session id is invalid")
|
||||
if result_id is not None and _SAFE_RESULT_ID.fullmatch(result_id) is None:
|
||||
raise ValueError("perception result id is invalid")
|
||||
with self._lock:
|
||||
result = (
|
||||
self._latest_unlocked(session_id)
|
||||
if result_id is None
|
||||
else self._resolve_unlocked(session_id, result_id)
|
||||
)
|
||||
if result is None:
|
||||
return None
|
||||
artifact = result.artifact("panoptic-overlay-video")
|
||||
duration, codec, width, height = self._probe_video(artifact)
|
||||
expected = result.job.timeline_end_seconds - result.job.timeline_start_seconds
|
||||
if codec != "h264" or width != 800 or height != 600 or abs(duration - expected) > 1:
|
||||
raise SessionIntegrityError("panoptic video stream contract is invalid")
|
||||
suffix = result.job.source_id.removeprefix("sensor.camera.")
|
||||
return RecordedPerceptionVideo(
|
||||
result_id=result.result_id,
|
||||
session_id=result.job.session_id,
|
||||
source_id=result.job.source_id,
|
||||
public_source_id=f"recorded.perception.{suffix}",
|
||||
label=f"Сегментация · камера {suffix}",
|
||||
path=artifact.path,
|
||||
media_type='video/mp4; codecs="avc1.640028"',
|
||||
byte_length=artifact.byte_length,
|
||||
sha256=artifact.sha256,
|
||||
timeline_start_seconds=result.job.timeline_start_seconds,
|
||||
timeline_end_seconds=result.job.timeline_end_seconds,
|
||||
)
|
||||
|
||||
def _latest_unlocked(self, session_id: str) -> RecordedPerceptionEpochResult | None:
|
||||
cached = self._latest_result_cache.get(session_id)
|
||||
if cached is not None:
|
||||
if self._latest_cache_is_current(cached):
|
||||
return cached.result
|
||||
self._latest_result_cache.pop(session_id, None)
|
||||
try:
|
||||
job_roots = sorted(self.jobs_root.iterdir())
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
if len(job_roots) > MAX_JOB_SCAN:
|
||||
raise SessionIntegrityError("compute job catalog is outside bounds")
|
||||
matches: list[RecordedPerceptionEpochResult] = []
|
||||
for job_root in job_roots:
|
||||
if job_root.is_symlink():
|
||||
continue
|
||||
try:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if job.session_id != session_id:
|
||||
continue
|
||||
parent = self.results_root / job.job_id
|
||||
try:
|
||||
result_roots = sorted(
|
||||
path
|
||||
for path in parent.iterdir()
|
||||
if path.is_dir()
|
||||
and not path.is_symlink()
|
||||
and _SAFE_RESULT_ID.fullmatch(path.name) is not None
|
||||
)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
for result_root in result_roots:
|
||||
try:
|
||||
matches.append(validate_recorded_perception_epoch_result(job_root, result_root))
|
||||
except (OSError, SessionIntegrityError):
|
||||
continue
|
||||
if not matches:
|
||||
return None
|
||||
latest = max(matches, key=lambda value: (value.created_at_utc, value.result_id))
|
||||
self._latest_result_cache[session_id] = self._latest_cache_entry(latest)
|
||||
return latest
|
||||
|
||||
def _latest_cache_entry(
|
||||
self,
|
||||
result: RecordedPerceptionEpochResult,
|
||||
) -> _LatestResultCacheEntry:
|
||||
result_parent = self.results_root / result.job.job_id
|
||||
return _LatestResultCacheEntry(
|
||||
jobs_root_mtime_ns=self.jobs_root.stat().st_mtime_ns,
|
||||
result_parent_mtime_ns=result_parent.stat().st_mtime_ns,
|
||||
file_fingerprint=_result_file_fingerprint(result),
|
||||
result=result,
|
||||
)
|
||||
|
||||
def _latest_cache_is_current(self, cached: _LatestResultCacheEntry) -> bool:
|
||||
"""Reuse a fully validated immutable publication while it is unchanged.
|
||||
|
||||
New jobs and new result generations invalidate through their parent
|
||||
directory mtimes. In-place changes to the manifest or any served
|
||||
result artifact invalidate through the bounded file fingerprint and
|
||||
force the full hash validation again.
|
||||
"""
|
||||
|
||||
try:
|
||||
result_parent = self.results_root / cached.result.job.job_id
|
||||
return (
|
||||
self.jobs_root.stat().st_mtime_ns == cached.jobs_root_mtime_ns
|
||||
and result_parent.stat().st_mtime_ns == cached.result_parent_mtime_ns
|
||||
and _result_file_fingerprint(cached.result) == cached.file_fingerprint
|
||||
)
|
||||
except (OSError, SessionIntegrityError):
|
||||
return False
|
||||
|
||||
def _resolve_unlocked(
|
||||
self,
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
) -> RecordedPerceptionEpochResult | None:
|
||||
latest = self._latest_unlocked(session_id)
|
||||
if latest is None or latest.result_id != result_id:
|
||||
return None
|
||||
return latest
|
||||
|
||||
def _probe_video(self, artifact: PerceptionArtifact) -> tuple[float, str, int, int]:
|
||||
metadata = _confined_regular_file(artifact.path, artifact.path.parent)
|
||||
key = (artifact.sha256, metadata.st_mtime_ns, metadata.st_size)
|
||||
cached = self._video_probe_cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(self.ffprobe_path),
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name,width,height:format=duration",
|
||||
"-of",
|
||||
"json",
|
||||
str(artifact.path),
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
document = json.loads(completed.stdout)
|
||||
stream = document["streams"][0]
|
||||
value = (
|
||||
float(document["format"]["duration"]),
|
||||
str(stream["codec_name"]),
|
||||
int(stream["width"]),
|
||||
int(stream["height"]),
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, ValueError, KeyError, IndexError) as exc:
|
||||
raise SessionIntegrityError("panoptic video metadata is unavailable") from exc
|
||||
if completed.returncode != 0 or not math.isfinite(value[0]) or value[0] <= 0:
|
||||
raise SessionIntegrityError("panoptic video could not be decoded")
|
||||
self._video_probe_cache = {key: value}
|
||||
return value
|
||||
|
||||
|
||||
def validate_recorded_perception_epoch_result(
|
||||
job_root: Path,
|
||||
result_root: Path,
|
||||
) -> RecordedPerceptionEpochResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("full-epoch perception result root is invalid")
|
||||
result = _read_json_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"result-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or result.get("job_id") != job.job_id
|
||||
or result.get("input_sha256") != job.input_sha256
|
||||
or result.get("session_id") != job.session_id
|
||||
or result.get("source_id") != job.source_id
|
||||
or result.get("codec_epoch") != job.codec_epoch
|
||||
or result.get("timestamp_basis") != "session-time-seconds"
|
||||
or result.get("timeline_start_seconds") != job.timeline_start_seconds
|
||||
or result.get("timeline_end_seconds") != job.timeline_end_seconds
|
||||
or result.get("frames_processed") != job.segment_count
|
||||
):
|
||||
raise SessionIntegrityError("full-epoch perception result identity is inconsistent")
|
||||
if identity.get("job_id") != job.job_id or identity.get("input_sha256") != job.input_sha256:
|
||||
raise SessionIntegrityError("full-epoch perception identity is not bound to its job")
|
||||
calibration = identity.get("calibration")
|
||||
if (
|
||||
not isinstance(calibration, dict)
|
||||
or not isinstance(calibration.get("content_identity_sha256"), str)
|
||||
or _SHA256.fullmatch(calibration["content_identity_sha256"]) is None
|
||||
or not isinstance(calibration.get("camera_slot"), str)
|
||||
or not calibration["camera_slot"]
|
||||
):
|
||||
raise SessionIntegrityError("full-epoch perception calibration binding is invalid")
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
report_artifact = _artifact(artifacts, "perception-run-report")
|
||||
report = _read_json_object(report_artifact.path, root)
|
||||
metrics = report.get("metrics")
|
||||
report_input = report.get("input")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("state") != "published"
|
||||
or report.get("result_id") != root.name
|
||||
or not isinstance(metrics, dict)
|
||||
or metrics.get("frames_expected") != job.segment_count
|
||||
or metrics.get("frames_processed") != job.segment_count
|
||||
or metrics.get("frames_failed") != 0
|
||||
or metrics.get("frames_skipped") != 0
|
||||
or not isinstance(report_input, dict)
|
||||
or report_input.get("job_id") != job.job_id
|
||||
or report_input.get("input_sha256") != job.input_sha256
|
||||
):
|
||||
raise SessionIntegrityError("full-epoch perception run report is inconsistent")
|
||||
_validate_frame_metadata(_artifact(artifacts, "panoptic-frame-metadata"), job)
|
||||
created_at_utc = result.get("created_at_utc")
|
||||
if not isinstance(created_at_utc, str) or not 1 <= len(created_at_utc) <= 64:
|
||||
raise SessionIntegrityError("full-epoch perception creation time is invalid")
|
||||
return RecordedPerceptionEpochResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
created_at_utc=created_at_utc,
|
||||
calibration_sha256=calibration["content_identity_sha256"],
|
||||
calibration_slot=calibration["camera_slot"],
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifacts(root: Path, raw: object) -> tuple[PerceptionArtifact, ...]:
|
||||
expected = {
|
||||
"panoptic-overlay-video": ("perception.mp4", "video/mp4", None),
|
||||
"panoptic-mask-archive": ("masks.tar.gz", "application/gzip", None),
|
||||
"panoptic-frame-metadata": ("frames.jsonl", "application/x-ndjson", FRAME_SCHEMA),
|
||||
"worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None),
|
||||
"perception-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("full-epoch perception artifact set is invalid")
|
||||
artifacts: list[PerceptionArtifact] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("full-epoch perception artifact is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("full-epoch perception artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
expected_path, expected_media_type, expected_schema = expected[kind]
|
||||
path = root / expected_path
|
||||
metadata = _confined_regular_file(path, root)
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
value.get("path") != expected_path
|
||||
or value.get("media_type") != expected_media_type
|
||||
or value.get("schema_version") != expected_schema
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or not 0 < byte_length <= MAX_SAFE_INTEGER
|
||||
or metadata.st_size != byte_length
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or _sha256_file(path) != digest
|
||||
):
|
||||
raise SessionIntegrityError("full-epoch perception artifact identity changed")
|
||||
artifacts.append(
|
||||
PerceptionArtifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
media_type=expected_media_type,
|
||||
byte_length=byte_length,
|
||||
sha256=digest,
|
||||
schema_version=expected_schema,
|
||||
)
|
||||
)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _artifact(artifacts: tuple[PerceptionArtifact, ...], kind: str) -> PerceptionArtifact:
|
||||
matches = tuple(item for item in artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError("full-epoch perception artifact is unavailable")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _validate_frame_metadata(artifact: PerceptionArtifact, job: CameraComputeJob) -> None:
|
||||
previous = -math.inf
|
||||
count = 0
|
||||
try:
|
||||
with artifact.path.open("rb") as stream:
|
||||
for expected_index, line in enumerate(stream):
|
||||
if not line or len(line) > MAX_FRAME_LINE_BYTES:
|
||||
raise SessionIntegrityError("panoptic frame metadata line is outside bounds")
|
||||
value = json.loads(line)
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != FRAME_SCHEMA
|
||||
or value.get("frame_index") != expected_index
|
||||
or value.get("sequence") != expected_index + 1
|
||||
or not isinstance(session_seconds, (int, float))
|
||||
or isinstance(session_seconds, bool)
|
||||
or not math.isfinite(float(session_seconds))
|
||||
or float(session_seconds) <= previous
|
||||
or float(session_seconds) < job.timeline_start_seconds - 0.001
|
||||
or float(session_seconds) > job.timeline_end_seconds + 0.001
|
||||
or not isinstance(value.get("instances"), list)
|
||||
or not isinstance(value.get("semantic_classes"), list)
|
||||
):
|
||||
raise SessionIntegrityError("panoptic frame metadata is inconsistent")
|
||||
previous = float(session_seconds)
|
||||
count += 1
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("panoptic frame metadata is unavailable") from exc
|
||||
if count != job.segment_count:
|
||||
raise SessionIntegrityError("panoptic frame metadata count changed")
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("full-epoch perception JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("full-epoch perception JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("full-epoch perception JSON is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("full-epoch perception artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("full-epoch perception artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _result_file_fingerprint(
|
||||
result: RecordedPerceptionEpochResult,
|
||||
) -> tuple[tuple[str, int, int, int, int], ...]:
|
||||
paths = (result.job.manifest_path, result.result_root / "result.json") + tuple(
|
||||
artifact.path for artifact in result.artifacts
|
||||
)
|
||||
values: list[tuple[str, int, int, int, int]] = []
|
||||
for path in paths:
|
||||
root = result.job.job_root if path == result.job.manifest_path else result.result_root
|
||||
metadata = _confined_regular_file(path, root)
|
||||
values.append(
|
||||
(
|
||||
str(path),
|
||||
metadata.st_dev,
|
||||
metadata.st_ino,
|
||||
metadata.st_size,
|
||||
metadata.st_mtime_ns,
|
||||
)
|
||||
)
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("full-epoch perception identity cannot be encoded") from exc
|
||||
@@ -0,0 +1,401 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
QUALIFICATION_SLICE_SCHEMA = "missioncore.recorded-qualification-slice/v1"
|
||||
QUALIFICATION_SLICE_IDENTITY_SCHEMA = "missioncore.recorded-qualification-slice-identity/v1"
|
||||
QUALIFICATION_POLICY = "uniform-frame-index-full-epoch/v1"
|
||||
DEFAULT_QUALIFICATION_FRAME_COUNT = 256
|
||||
MAX_QUALIFICATION_FRAME_COUNT = 4096
|
||||
MAX_SLICE_MANIFEST_BYTES = 16 * 1024 * 1024
|
||||
MAX_INDEX_LINE_BYTES = 16 * 1024
|
||||
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_SAFE_GENERATION = re.compile(r"^qualification-slice-[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class RecordedQualificationSliceError(RuntimeError):
|
||||
"""A deterministic recorded-camera qualification slice is invalid."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class QualificationFrame:
|
||||
frame_index: int
|
||||
sequence: int
|
||||
segment_sha256: str
|
||||
host_epoch_ns: int
|
||||
host_monotonic_ns: int
|
||||
archive_session_monotonic_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordedQualificationSlice:
|
||||
generation_id: str
|
||||
root: Path
|
||||
manifest_path: Path
|
||||
job_id: str
|
||||
input_sha256: str
|
||||
policy: str
|
||||
source_frame_count: int
|
||||
frames: tuple[QualificationFrame, ...]
|
||||
|
||||
|
||||
def prepare_recorded_qualification_slice(
|
||||
*,
|
||||
job_root: Path,
|
||||
output_root: Path,
|
||||
sample_count: int = DEFAULT_QUALIFICATION_FRAME_COUNT,
|
||||
) -> RecordedQualificationSlice:
|
||||
"""Seal an evenly distributed, exact-repeat frame-index slice.
|
||||
|
||||
The selection includes both ends of the epoch and is keyed by the complete
|
||||
compute-job input identity. It deliberately selects by decoded frame index;
|
||||
exact PTS are attached by the worker after decoding, while archive arrival
|
||||
timestamps remain diagnostic metadata only.
|
||||
"""
|
||||
|
||||
job = validate_camera_compute_job(job_root)
|
||||
if (
|
||||
not isinstance(sample_count, int)
|
||||
or isinstance(sample_count, bool)
|
||||
or not 1 <= sample_count <= MAX_QUALIFICATION_FRAME_COUNT
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification sample count is outside bounds")
|
||||
admitted_count = min(sample_count, job.segment_count)
|
||||
selected_indices = _uniform_indices(job.segment_count, admitted_count)
|
||||
index_rows = _read_archive_index(job)
|
||||
frames = tuple(_frame_from_index(index_rows[index], index) for index in selected_indices)
|
||||
|
||||
identity = {
|
||||
"schema_version": QUALIFICATION_SLICE_IDENTITY_SCHEMA,
|
||||
"job_id": job.job_id,
|
||||
"input_sha256": job.input_sha256,
|
||||
"session_id": job.session_id,
|
||||
"source_id": job.source_id,
|
||||
"codec_epoch": job.codec_epoch,
|
||||
"source_frame_count": job.segment_count,
|
||||
"policy": QUALIFICATION_POLICY,
|
||||
"requested_sample_count": sample_count,
|
||||
"admitted_sample_count": len(frames),
|
||||
"selected_frames": [
|
||||
{
|
||||
"frame_index": frame.frame_index,
|
||||
"sequence": frame.sequence,
|
||||
"segment_sha256": frame.segment_sha256,
|
||||
}
|
||||
for frame in frames
|
||||
],
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"qualification-slice-{identity_sha256}"
|
||||
root = _prepare_private_directory(output_root)
|
||||
final = root / generation_id
|
||||
if final.exists():
|
||||
existing = validate_recorded_qualification_slice(final, job_root=job.job_root)
|
||||
if existing.input_sha256 != job.input_sha256:
|
||||
raise RecordedQualificationSliceError(
|
||||
"qualification generation collides with another input"
|
||||
)
|
||||
return existing
|
||||
|
||||
staging = root / f".{generation_id}.{secrets.token_hex(12)}.incomplete"
|
||||
published = False
|
||||
try:
|
||||
staging.mkdir(mode=0o700)
|
||||
manifest = {
|
||||
"schema_version": QUALIFICATION_SLICE_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"source_timeline": {
|
||||
"basis": "session-time-seconds",
|
||||
"start_seconds": job.timeline_start_seconds,
|
||||
"end_seconds": job.timeline_end_seconds,
|
||||
"duration_seconds": job.timeline_end_seconds - job.timeline_start_seconds,
|
||||
},
|
||||
"frames": [
|
||||
{
|
||||
"frame_index": frame.frame_index,
|
||||
"sequence": frame.sequence,
|
||||
"segment_sha256": frame.segment_sha256,
|
||||
"archive_host_epoch_ns": frame.host_epoch_ns,
|
||||
"archive_host_monotonic_ns": frame.host_monotonic_ns,
|
||||
"archive_session_monotonic_ns": frame.archive_session_monotonic_ns,
|
||||
"decoded_session_seconds": None,
|
||||
}
|
||||
for frame in frames
|
||||
],
|
||||
"usage": {
|
||||
"selection_basis": "zero-based decoded frame index",
|
||||
"worker_timestamp_binding": (
|
||||
"resolve exact best_effort_timestamp_time after full stream decode"
|
||||
),
|
||||
"archive_timestamp_status": "host-arrival-best-effort-diagnostic",
|
||||
"comparison_contract": (
|
||||
"all E1 variants must use this exact ordered frame list and the same input "
|
||||
"and calibration generations"
|
||||
),
|
||||
},
|
||||
}
|
||||
write_json_atomic(staging / "manifest.json", manifest)
|
||||
os.chmod(staging / "manifest.json", 0o600)
|
||||
_fsync_directory(staging)
|
||||
os.replace(staging, final)
|
||||
_fsync_directory(root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
return validate_recorded_qualification_slice(final, job_root=job.job_root)
|
||||
|
||||
|
||||
def validate_recorded_qualification_slice(
|
||||
slice_root: Path,
|
||||
*,
|
||||
job_root: Path,
|
||||
) -> RecordedQualificationSlice:
|
||||
root = slice_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_GENERATION.fullmatch(root.name) is None:
|
||||
raise RecordedQualificationSliceError("qualification slice root is invalid")
|
||||
manifest = _read_json_object(root / "manifest.json", root)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != QUALIFICATION_SLICE_SCHEMA
|
||||
or manifest.get("generation_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != QUALIFICATION_SLICE_IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"qualification-slice-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification slice identity is inconsistent")
|
||||
job = validate_camera_compute_job(job_root)
|
||||
if (
|
||||
identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("session_id") != job.session_id
|
||||
or identity.get("source_id") != job.source_id
|
||||
or identity.get("codec_epoch") != job.codec_epoch
|
||||
or identity.get("source_frame_count") != job.segment_count
|
||||
or identity.get("policy") != QUALIFICATION_POLICY
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification slice is not bound to the job")
|
||||
rows = manifest.get("frames")
|
||||
selected = identity.get("selected_frames")
|
||||
admitted_count = identity.get("admitted_sample_count")
|
||||
requested_count = identity.get("requested_sample_count")
|
||||
if (
|
||||
not isinstance(rows, list)
|
||||
or not isinstance(selected, list)
|
||||
or rows == []
|
||||
or not isinstance(admitted_count, int)
|
||||
or isinstance(admitted_count, bool)
|
||||
or admitted_count != len(rows)
|
||||
or len(selected) != len(rows)
|
||||
or not isinstance(requested_count, int)
|
||||
or isinstance(requested_count, bool)
|
||||
or not 1 <= requested_count <= MAX_QUALIFICATION_FRAME_COUNT
|
||||
or admitted_count != min(requested_count, job.segment_count)
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification slice frame count is invalid")
|
||||
archive_rows = _read_archive_index(job)
|
||||
frames: list[QualificationFrame] = []
|
||||
previous_index = -1
|
||||
for row, selected_row in zip(rows, selected, strict=True):
|
||||
if not isinstance(row, dict) or not isinstance(selected_row, dict):
|
||||
raise RecordedQualificationSliceError("qualification frame descriptor is invalid")
|
||||
frame_index = row.get("frame_index")
|
||||
if (
|
||||
not isinstance(frame_index, int)
|
||||
or isinstance(frame_index, bool)
|
||||
or not previous_index < frame_index < job.segment_count
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification frame order is invalid")
|
||||
expected = _frame_from_index(archive_rows[frame_index], frame_index)
|
||||
expected_identity = {
|
||||
"frame_index": expected.frame_index,
|
||||
"sequence": expected.sequence,
|
||||
"segment_sha256": expected.segment_sha256,
|
||||
}
|
||||
expected_row = {
|
||||
**expected_identity,
|
||||
"archive_host_epoch_ns": expected.host_epoch_ns,
|
||||
"archive_host_monotonic_ns": expected.host_monotonic_ns,
|
||||
"archive_session_monotonic_ns": expected.archive_session_monotonic_ns,
|
||||
"decoded_session_seconds": None,
|
||||
}
|
||||
if selected_row != expected_identity or row != expected_row:
|
||||
raise RecordedQualificationSliceError("qualification frame binding changed")
|
||||
frames.append(expected)
|
||||
previous_index = frame_index
|
||||
if tuple(frame.frame_index for frame in frames) != _uniform_indices(
|
||||
job.segment_count,
|
||||
admitted_count,
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification selection policy changed")
|
||||
return RecordedQualificationSlice(
|
||||
generation_id=root.name,
|
||||
root=root,
|
||||
manifest_path=root / "manifest.json",
|
||||
job_id=job.job_id,
|
||||
input_sha256=job.input_sha256,
|
||||
policy=QUALIFICATION_POLICY,
|
||||
source_frame_count=job.segment_count,
|
||||
frames=tuple(frames),
|
||||
)
|
||||
|
||||
|
||||
def _uniform_indices(source_count: int, sample_count: int) -> tuple[int, ...]:
|
||||
if source_count < 1 or not 1 <= sample_count <= source_count:
|
||||
raise RecordedQualificationSliceError("uniform selection bounds are invalid")
|
||||
if sample_count == 1:
|
||||
return (source_count // 2,)
|
||||
denominator = sample_count - 1
|
||||
indices = tuple(
|
||||
(position * (source_count - 1) + denominator // 2) // denominator
|
||||
for position in range(sample_count)
|
||||
)
|
||||
if len(set(indices)) != sample_count or indices[0] != 0 or indices[-1] != source_count - 1:
|
||||
raise RecordedQualificationSliceError("uniform selection is not exact")
|
||||
return indices
|
||||
|
||||
|
||||
def _read_archive_index(job: CameraComputeJob) -> list[dict[str, Any]]:
|
||||
index_path = (
|
||||
job.job_root
|
||||
/ "input"
|
||||
/ "camera"
|
||||
/ job.source_id
|
||||
/ f"epoch-{job.codec_epoch}"
|
||||
/ "index.jsonl"
|
||||
)
|
||||
try:
|
||||
metadata = index_path.lstat()
|
||||
resolved = index_path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise RecordedQualificationSliceError("camera archive index is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(job.job_root)
|
||||
):
|
||||
raise RecordedQualificationSliceError("camera archive index is not confined")
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
for expected_index, line in enumerate(stream):
|
||||
if len(line.encode("utf-8")) > MAX_INDEX_LINE_BYTES:
|
||||
raise RecordedQualificationSliceError("camera archive index row is too large")
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise RecordedQualificationSliceError("camera archive index row is invalid")
|
||||
if value.get("sequence") != expected_index + 1 or value.get("kind") != "media":
|
||||
raise RecordedQualificationSliceError("camera archive index order changed")
|
||||
rows.append(value)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecordedQualificationSliceError("camera archive index could not be read") from exc
|
||||
if len(rows) != job.segment_count:
|
||||
raise RecordedQualificationSliceError("camera archive index count changed")
|
||||
return rows
|
||||
|
||||
|
||||
def _frame_from_index(row: dict[str, Any], frame_index: int) -> QualificationFrame:
|
||||
sequence = row.get("sequence")
|
||||
segment_sha256 = row.get("sha256")
|
||||
host_epoch_ns = row.get("host_epoch_ns")
|
||||
host_monotonic_ns = row.get("host_monotonic_ns")
|
||||
session_monotonic_ns = row.get("session_monotonic_ns")
|
||||
integers = (sequence, host_epoch_ns, host_monotonic_ns, session_monotonic_ns)
|
||||
if (
|
||||
not all(
|
||||
isinstance(value, int) and not isinstance(value, bool) and value >= 0
|
||||
for value in integers
|
||||
)
|
||||
or sequence != frame_index + 1
|
||||
or not isinstance(segment_sha256, str)
|
||||
or _SHA256.fullmatch(segment_sha256) is None
|
||||
):
|
||||
raise RecordedQualificationSliceError("camera archive frame identity is invalid")
|
||||
return QualificationFrame(
|
||||
frame_index=frame_index,
|
||||
sequence=sequence,
|
||||
segment_sha256=segment_sha256,
|
||||
host_epoch_ns=cast(int, host_epoch_ns),
|
||||
host_monotonic_ns=cast(int, host_monotonic_ns),
|
||||
archive_session_monotonic_ns=cast(int, session_monotonic_ns),
|
||||
)
|
||||
|
||||
|
||||
def _prepare_private_directory(path: Path) -> Path:
|
||||
candidate = path.expanduser()
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
metadata = candidate.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise RecordedQualificationSliceError("qualification output root must be a real directory")
|
||||
root = candidate.resolve(strict=True)
|
||||
os.chmod(root, 0o700)
|
||||
return root
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
resolved = path.resolve(strict=True)
|
||||
except OSError as exc:
|
||||
raise RecordedQualificationSliceError("qualification manifest is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(root)
|
||||
or not 0 < metadata.st_size <= MAX_SLICE_MANIFEST_BYTES
|
||||
):
|
||||
raise RecordedQualificationSliceError("qualification manifest is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecordedQualificationSliceError("qualification manifest could not be read") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RecordedQualificationSliceError("qualification manifest is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("qualification identity is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,466 @@
|
||||
"""Validation for immutable LAB E8 source-paced tracking results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e8-realtime-tracking-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e8-realtime-tracking-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e8-realtime-tracking-report/v1"
|
||||
FRAME_SCHEMA = "missioncore.e8-realtime-tracking-frame/v1"
|
||||
TELEMETRY_SCHEMA = "missioncore.e8-realtime-tracking-telemetry/v1"
|
||||
PROFILE_SCHEMA = "missioncore.e8-realtime-tracking-profile/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_LINE_BYTES = 2 * 1024 * 1024
|
||||
MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e8-realtime-tracking-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealtimeTrackingArtifact:
|
||||
kind: str
|
||||
path: Path
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str
|
||||
schema_version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealtimeTrackingQualificationResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
mode: str
|
||||
expected_frame_count: int
|
||||
processed_frame_count: int
|
||||
dropped_frame_count: int
|
||||
accepted: bool
|
||||
artifacts: tuple[RealtimeTrackingArtifact, ...]
|
||||
|
||||
def artifact(self, kind: str) -> RealtimeTrackingArtifact:
|
||||
matches = tuple(item for item in self.artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"realtime tracking artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def validate_realtime_tracking_qualification_result(
|
||||
job_root: Path,
|
||||
result_root: Path,
|
||||
) -> RealtimeTrackingQualificationResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("realtime tracking result root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e8-realtime-tracking-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("session_id") != job.session_id
|
||||
or identity.get("source_id") != job.source_id
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope") != "recorded-realtime-qualification-only"
|
||||
or result.get("acceptance_state") not in {"accepted", "rejected"}
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking identity is inconsistent")
|
||||
|
||||
selection = identity.get("selection")
|
||||
configuration = identity.get("configuration")
|
||||
if not isinstance(selection, dict) or not isinstance(configuration, dict):
|
||||
raise SessionIntegrityError("realtime tracking selection is unavailable")
|
||||
expected = selection.get("frame_count")
|
||||
source_start = selection.get("source_start_frame_index")
|
||||
source_end = selection.get("source_end_frame_index")
|
||||
timeline_start = selection.get("timeline_start_seconds")
|
||||
timeline_end = selection.get("timeline_end_seconds")
|
||||
profile = configuration.get("profile")
|
||||
if (
|
||||
not isinstance(expected, int)
|
||||
or isinstance(expected, bool)
|
||||
or expected < 2
|
||||
or not isinstance(source_start, int)
|
||||
or isinstance(source_start, bool)
|
||||
or not isinstance(source_end, int)
|
||||
or isinstance(source_end, bool)
|
||||
or source_start < 0
|
||||
or source_end - source_start + 1 != expected
|
||||
or source_end >= job.segment_count
|
||||
or not _finite_number(timeline_start)
|
||||
or not _finite_number(timeline_end)
|
||||
or float(timeline_start) < job.timeline_start_seconds
|
||||
or float(timeline_end) > job.timeline_end_seconds
|
||||
or float(timeline_end) <= float(timeline_start)
|
||||
or not isinstance(profile, dict)
|
||||
or profile.get("schema_version") != PROFILE_SCHEMA
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking selection is invalid")
|
||||
mode = profile.get("mode")
|
||||
realtime = profile.get("realtime")
|
||||
if (
|
||||
mode not in {"pilot", "qualification", "overload-negative-control"}
|
||||
or not isinstance(realtime, dict)
|
||||
or realtime.get("queue_policy") != "bounded-latest-wins"
|
||||
or not isinstance(realtime.get("queue_capacity"), int)
|
||||
or not 1 <= int(realtime["queue_capacity"]) <= 8
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking profile is invalid")
|
||||
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
report = _read_object(_artifact(artifacts, "realtime-tracking-report").path, root)
|
||||
metrics = report.get("metrics")
|
||||
acceptance = report.get("acceptance")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("identity") != identity
|
||||
or report.get("ground_truth") is not False
|
||||
or report.get("state") != result.get("acceptance_state")
|
||||
or not isinstance(metrics, dict)
|
||||
or not isinstance(acceptance, dict)
|
||||
or not isinstance(acceptance.get("accepted"), bool)
|
||||
or acceptance.get("navigation_or_safety_accepted") is not False
|
||||
or (result.get("acceptance_state") == "accepted") != acceptance["accepted"]
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking report is inconsistent")
|
||||
processed = metrics.get("frames_processed")
|
||||
dropped = metrics.get("frames_dropped")
|
||||
queue = metrics.get("queue")
|
||||
health = metrics.get("health_counts")
|
||||
if (
|
||||
metrics.get("frames_expected") != expected
|
||||
or metrics.get("frames_published") != expected
|
||||
or not isinstance(processed, int)
|
||||
or isinstance(processed, bool)
|
||||
or not 1 <= processed <= expected
|
||||
or not isinstance(dropped, int)
|
||||
or isinstance(dropped, bool)
|
||||
or dropped < 0
|
||||
or processed + dropped != expected
|
||||
or result.get("frames_processed") != processed
|
||||
or metrics.get("frames_failed") != 0
|
||||
or not isinstance(queue, dict)
|
||||
or queue.get("capacity") != realtime["queue_capacity"]
|
||||
or queue.get("published") != expected
|
||||
or queue.get("consumed") != processed
|
||||
or queue.get("dropped_overflow") != dropped
|
||||
or queue.get("final_depth") != 0
|
||||
or not isinstance(queue.get("maximum_depth"), int)
|
||||
or not 0 <= queue["maximum_depth"] <= queue["capacity"]
|
||||
or queue.get("closed") is not True
|
||||
or not isinstance(health, dict)
|
||||
or sum(health.values()) != processed
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking accounting is inconsistent")
|
||||
|
||||
indices = _validate_realtime_frames(
|
||||
_artifact(artifacts, "realtime-tracking-frames").path,
|
||||
root,
|
||||
expected_count=processed,
|
||||
selection_count=expected,
|
||||
source_start=source_start,
|
||||
timeline_start=float(timeline_start),
|
||||
timeline_end=float(timeline_end),
|
||||
)
|
||||
_validate_realtime_telemetry(
|
||||
_artifact(artifacts, "realtime-tracking-telemetry").path,
|
||||
root,
|
||||
expected_indices=indices,
|
||||
)
|
||||
return RealtimeTrackingQualificationResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
mode=str(mode),
|
||||
expected_frame_count=expected,
|
||||
processed_frame_count=processed,
|
||||
dropped_frame_count=dropped,
|
||||
accepted=bool(acceptance["accepted"]),
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_realtime_frames(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
selection_count: int,
|
||||
source_start: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
) -> tuple[int, ...]:
|
||||
_confined_regular_file(path, root)
|
||||
indices: list[int] = []
|
||||
previous_time = -math.inf
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
for line in stream:
|
||||
if not line or len(line) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("realtime tracking frame line is outside bounds")
|
||||
value = json.loads(line)
|
||||
frame_index = value.get("frame_index") if isinstance(value, dict) else None
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
detections = value.get("detections") if isinstance(value, dict) else None
|
||||
tracks = value.get("tracks") if isinstance(value, dict) else None
|
||||
delivery = value.get("delivery") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != FRAME_SCHEMA
|
||||
or not isinstance(frame_index, int)
|
||||
or isinstance(frame_index, bool)
|
||||
or not 0 <= frame_index < selection_count
|
||||
or (indices and frame_index <= indices[-1])
|
||||
or value.get("sequence") != frame_index + 1
|
||||
or value.get("source_frame_index") != source_start + frame_index
|
||||
or value.get("source_sequence") != source_start + frame_index + 1
|
||||
or not _finite_number(session_seconds)
|
||||
or not timeline_start - 0.001 <= float(session_seconds) <= timeline_end + 0.001
|
||||
or float(session_seconds) <= previous_time
|
||||
or not isinstance(detections, list)
|
||||
or not isinstance(tracks, list)
|
||||
or not isinstance(delivery, dict)
|
||||
or delivery.get("health") not in {"healthy", "degraded", "stale", "unavailable"}
|
||||
or not _finite_number(delivery.get("result_age_ms"))
|
||||
or float(delivery["result_age_ms"]) < 0
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking frame metadata is inconsistent")
|
||||
_validate_observations(detections, tracks)
|
||||
indices.append(frame_index)
|
||||
previous_time = float(session_seconds)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("realtime tracking frames are unavailable") from exc
|
||||
if len(indices) != expected_count:
|
||||
raise SessionIntegrityError("realtime tracking frame count changed")
|
||||
return tuple(indices)
|
||||
|
||||
|
||||
def _validate_realtime_telemetry(
|
||||
path: Path,
|
||||
root: Path,
|
||||
*,
|
||||
expected_indices: tuple[int, ...],
|
||||
) -> None:
|
||||
_confined_regular_file(path, root)
|
||||
indices: list[int] = []
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
for line in stream:
|
||||
if not line or len(line) > MAX_LINE_BYTES:
|
||||
raise SessionIntegrityError("realtime telemetry line is outside bounds")
|
||||
value = json.loads(line)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != TELEMETRY_SCHEMA
|
||||
or not isinstance(value.get("frame_index"), int)
|
||||
or value.get("health") not in {"healthy", "degraded", "stale", "unavailable"}
|
||||
or not _finite_number(value.get("result_age_ms"))
|
||||
or float(value["result_age_ms"]) < 0
|
||||
or not _finite_number(value.get("processing_ms"))
|
||||
or float(value["processing_ms"]) < 0
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking telemetry is inconsistent")
|
||||
indices.append(int(value["frame_index"]))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("realtime tracking telemetry is unavailable") from exc
|
||||
if tuple(indices) != expected_indices:
|
||||
raise SessionIntegrityError("realtime tracking telemetry binding changed")
|
||||
|
||||
|
||||
def _validate_observations(detections: list[object], tracks: list[object]) -> None:
|
||||
for detection in detections:
|
||||
if not isinstance(detection, dict):
|
||||
raise SessionIntegrityError("realtime tracking detection is invalid")
|
||||
_validate_box(detection.get("bbox_xyxy"))
|
||||
if (
|
||||
not isinstance(detection.get("class_id"), int)
|
||||
or not isinstance(detection.get("label"), str)
|
||||
or not _finite_number(detection.get("score"))
|
||||
or not 0 <= float(detection["score"]) <= 1
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking detection fields are invalid")
|
||||
track_ids: set[int] = set()
|
||||
for track in tracks:
|
||||
if not isinstance(track, dict):
|
||||
raise SessionIntegrityError("realtime tracking observation is invalid")
|
||||
_validate_box(track.get("bbox_xyxy"))
|
||||
track_id = track.get("track_id")
|
||||
if (
|
||||
not isinstance(track_id, int)
|
||||
or isinstance(track_id, bool)
|
||||
or track_id < 1
|
||||
or track_id in track_ids
|
||||
or not isinstance(track.get("label"), str)
|
||||
or not _finite_number(track.get("score"))
|
||||
or not isinstance(track.get("hits"), int)
|
||||
or track["hits"] < 1
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking observation fields are invalid")
|
||||
track_ids.add(track_id)
|
||||
|
||||
|
||||
def _validate_box(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 4
|
||||
or not all(_finite_number(item) for item in value)
|
||||
or not 0 <= float(value[0]) < float(value[2]) <= 800
|
||||
or not 0 <= float(value[1]) < float(value[3]) <= 600
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking box is invalid")
|
||||
|
||||
|
||||
def _validate_artifacts(
|
||||
root: Path,
|
||||
raw: object,
|
||||
) -> tuple[RealtimeTrackingArtifact, ...]:
|
||||
expected = {
|
||||
"realtime-tracking-frames": (
|
||||
"frames.jsonl",
|
||||
"application/x-ndjson",
|
||||
FRAME_SCHEMA,
|
||||
),
|
||||
"realtime-tracking-telemetry": (
|
||||
"telemetry.jsonl",
|
||||
"application/x-ndjson",
|
||||
TELEMETRY_SCHEMA,
|
||||
),
|
||||
"worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None),
|
||||
"realtime-tracking-report": (
|
||||
"run-report.json",
|
||||
"application/json",
|
||||
REPORT_SCHEMA,
|
||||
),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("realtime tracking artifact set is invalid")
|
||||
artifacts: list[RealtimeTrackingArtifact] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("realtime tracking artifact descriptor is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("realtime tracking artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
expected_path, expected_media_type, expected_schema = expected[kind]
|
||||
path = root / expected_path
|
||||
metadata = _confined_regular_file(path, root)
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
value.get("path") != expected_path
|
||||
or value.get("media_type") != expected_media_type
|
||||
or value.get("schema_version") != expected_schema
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or not 0 < byte_length <= MAX_SAFE_INTEGER
|
||||
or metadata.st_size != byte_length
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or _sha256_file(path) != digest
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking artifact identity changed")
|
||||
artifacts.append(
|
||||
RealtimeTrackingArtifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
media_type=expected_media_type,
|
||||
byte_length=byte_length,
|
||||
sha256=digest,
|
||||
schema_version=expected_schema,
|
||||
)
|
||||
)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _artifact(
|
||||
artifacts: tuple[RealtimeTrackingArtifact, ...],
|
||||
kind: str,
|
||||
) -> RealtimeTrackingArtifact:
|
||||
matches = tuple(item for item in artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"realtime tracking artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("realtime tracking JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("realtime tracking JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("realtime tracking JSON root is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("realtime tracking artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("realtime tracking artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("realtime tracking identity is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _finite_number(value: object) -> TypeGuard[int | float]:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
@@ -0,0 +1,425 @@
|
||||
"""Validation for immutable LAB E6 tracked LiDAR qualification results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
import numpy as np
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
from .perception_epoch import (
|
||||
RecordedPerceptionEpochResult,
|
||||
validate_recorded_perception_epoch_result,
|
||||
)
|
||||
from .tracking_qualification import (
|
||||
TrackingQualificationResult,
|
||||
validate_tracking_qualification_result,
|
||||
)
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e6-tracked-lidar-fusion-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e6-tracked-lidar-fusion-identity/v1"
|
||||
FRAME_SCHEMA = "missioncore.e6-tracked-lidar-frame/v1"
|
||||
REPORT_SCHEMA = "missioncore.e6-tracked-lidar-run-report/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_FRAME_LINE_BYTES = 4 * 1024 * 1024
|
||||
MAX_POINTS_PER_FRAME = 100_000
|
||||
MAX_BOXES_PER_FRAME = 10_000
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e6-fusion-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackedFusionQualificationResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
tracking: TrackingQualificationResult
|
||||
semantic: RecordedPerceptionEpochResult
|
||||
frame_count: int
|
||||
artifacts: tuple[dict[str, Any], ...]
|
||||
|
||||
|
||||
def validate_tracked_fusion_qualification_result(
|
||||
job_root: Path,
|
||||
tracking_root: Path,
|
||||
semantic_root: Path,
|
||||
result_root: Path,
|
||||
) -> TrackedFusionQualificationResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
tracking = validate_tracking_qualification_result(job_root, tracking_root)
|
||||
semantic = validate_recorded_perception_epoch_result(job_root, semantic_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("tracked fusion result root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e6-fusion-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
or identity.get("tracking_result_id") != tracking.result_id
|
||||
or identity.get("semantic_result_id") != semantic.result_id
|
||||
or identity.get("calibration_sha256") != semantic.calibration_sha256
|
||||
or identity.get("camera_slot") != semantic.calibration_slot
|
||||
or result.get("session_id") != job.session_id
|
||||
or result.get("source_id") != job.source_id
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope") != "qualification-clip-only"
|
||||
or result.get("frames_processed") != tracking.frame_count
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion identity is inconsistent")
|
||||
clip = identity.get("clip")
|
||||
if (
|
||||
not isinstance(clip, dict)
|
||||
or clip.get("source_start_frame_index") != tracking.source_start_frame_index
|
||||
or clip.get("source_end_frame_index") != tracking.source_end_frame_index
|
||||
or clip.get("clip_frame_count") != tracking.frame_count
|
||||
or clip.get("timeline_start_seconds") != tracking.timeline_start_seconds
|
||||
or clip.get("timeline_end_seconds") != tracking.timeline_end_seconds
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion clip binding changed")
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
labels = _read_labels(_artifact_path(artifacts, "tracked-lidar-box-labels", root))
|
||||
_validate_arrays(
|
||||
_artifact_path(artifacts, "tracked-lidar-arrays", root),
|
||||
frame_count=tracking.frame_count,
|
||||
label_count=len(labels),
|
||||
timeline_start=tracking.timeline_start_seconds,
|
||||
timeline_end=tracking.timeline_end_seconds,
|
||||
)
|
||||
_validate_frames(
|
||||
_artifact_path(artifacts, "tracked-lidar-frame-metadata", root),
|
||||
tracking=tracking,
|
||||
)
|
||||
report = _read_object(
|
||||
_artifact_path(artifacts, "tracked-lidar-run-report", root), root
|
||||
)
|
||||
metrics = report.get("metrics")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("identity") != identity
|
||||
or not isinstance(metrics, dict)
|
||||
or metrics.get("frames_expected") != tracking.frame_count
|
||||
or metrics.get("frames_processed") != tracking.frame_count
|
||||
or metrics.get("frames_fused", 0) + metrics.get("frames_depth_unavailable", 0)
|
||||
!= tracking.frame_count
|
||||
or result.get("metrics") != metrics
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion report is inconsistent")
|
||||
rrd = _artifact_path(artifacts, "tracked-lidar-rerun", root)
|
||||
if rrd.read_bytes()[:4] != b"RRF2":
|
||||
raise SessionIntegrityError("tracked fusion Rerun stream is invalid")
|
||||
return TrackedFusionQualificationResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
tracking=tracking,
|
||||
semantic=semantic,
|
||||
frame_count=tracking.frame_count,
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifacts(root: Path, raw: object) -> tuple[dict[str, Any], ...]:
|
||||
expected = {
|
||||
"tracked-lidar-arrays": ("fusion.npz", "application/x-npz", None),
|
||||
"tracked-lidar-frame-metadata": (
|
||||
"fusion-frames.jsonl",
|
||||
"application/x-ndjson",
|
||||
FRAME_SCHEMA,
|
||||
),
|
||||
"tracked-lidar-box-labels": ("box-labels.json", "application/json", None),
|
||||
"tracked-lidar-overlay-video": ("fusion-overlay.mp4", "video/mp4", None),
|
||||
"tracked-lidar-rerun": ("fusion.rrd", "application/vnd.rerun.rrd", None),
|
||||
"tracked-lidar-contact-sheet": ("contact-sheet.png", "image/png", None),
|
||||
"tracked-lidar-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("tracked fusion artifact set is invalid")
|
||||
artifacts: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("tracked fusion artifact descriptor is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("tracked fusion artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
name, media_type, schema = expected[kind]
|
||||
path = root / name
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if (
|
||||
value.get("path") != name
|
||||
or value.get("media_type") != media_type
|
||||
or value.get("schema_version") != schema
|
||||
or value.get("byte_length") != metadata.st_size
|
||||
or not isinstance(value.get("sha256"), str)
|
||||
or _SHA256.fullmatch(str(value["sha256"])) is None
|
||||
or _sha256(path) != value["sha256"]
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion artifact identity changed")
|
||||
artifacts.append(value)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _validate_arrays(
|
||||
path: Path,
|
||||
*,
|
||||
frame_count: int,
|
||||
label_count: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
) -> None:
|
||||
required = {
|
||||
"frame_times_ns",
|
||||
"cloud_offsets",
|
||||
"cloud_points",
|
||||
"support_offsets",
|
||||
"support_points",
|
||||
"support_colors",
|
||||
"box_offsets",
|
||||
"box_centers",
|
||||
"box_half_sizes",
|
||||
"box_quaternions",
|
||||
"box_colors",
|
||||
"box_track_ids",
|
||||
"box_distances_m",
|
||||
"box_support_counts",
|
||||
}
|
||||
try:
|
||||
with np.load(path, allow_pickle=False) as arrays:
|
||||
if set(arrays.files) != required:
|
||||
raise SessionIntegrityError("tracked fusion array set is invalid")
|
||||
times = arrays["frame_times_ns"]
|
||||
cloud_offsets = arrays["cloud_offsets"]
|
||||
cloud_points = arrays["cloud_points"]
|
||||
support_offsets = arrays["support_offsets"]
|
||||
support_points = arrays["support_points"]
|
||||
support_colors = arrays["support_colors"]
|
||||
box_offsets = arrays["box_offsets"]
|
||||
centers = arrays["box_centers"]
|
||||
half_sizes = arrays["box_half_sizes"]
|
||||
quaternions = arrays["box_quaternions"]
|
||||
colors = arrays["box_colors"]
|
||||
track_ids = arrays["box_track_ids"]
|
||||
distances = arrays["box_distances_m"]
|
||||
support_counts = arrays["box_support_counts"]
|
||||
boxes = centers.shape[0]
|
||||
if (
|
||||
times.dtype != np.int64
|
||||
or times.shape != (frame_count,)
|
||||
or cloud_offsets.dtype != np.int64
|
||||
or cloud_offsets.shape != (frame_count + 1,)
|
||||
or cloud_points.dtype != np.float32
|
||||
or cloud_points.ndim != 2
|
||||
or cloud_points.shape[1:] != (3,)
|
||||
or support_offsets.dtype != np.int64
|
||||
or support_offsets.shape != (frame_count + 1,)
|
||||
or support_points.dtype != np.float32
|
||||
or support_points.ndim != 2
|
||||
or support_points.shape[1:] != (3,)
|
||||
or support_colors.dtype != np.uint8
|
||||
or support_colors.shape != support_points.shape
|
||||
or box_offsets.dtype != np.int64
|
||||
or box_offsets.shape != (frame_count + 1,)
|
||||
or centers.dtype != np.float32
|
||||
or centers.ndim != 2
|
||||
or centers.shape[1:] != (3,)
|
||||
or half_sizes.dtype != np.float32
|
||||
or half_sizes.shape != centers.shape
|
||||
or quaternions.dtype != np.float32
|
||||
or quaternions.shape != (boxes, 4)
|
||||
or colors.dtype != np.uint8
|
||||
or colors.shape != (boxes, 4)
|
||||
or track_ids.dtype != np.int64
|
||||
or track_ids.shape != (boxes,)
|
||||
or distances.dtype != np.float32
|
||||
or distances.shape != (boxes,)
|
||||
or support_counts.dtype != np.int64
|
||||
or support_counts.shape != (boxes,)
|
||||
or label_count != boxes
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion array shapes changed")
|
||||
_validate_offsets(cloud_offsets, cloud_points.shape[0], MAX_POINTS_PER_FRAME)
|
||||
_validate_offsets(support_offsets, support_points.shape[0], MAX_POINTS_PER_FRAME)
|
||||
_validate_offsets(box_offsets, boxes, MAX_BOXES_PER_FRAME)
|
||||
if (
|
||||
np.any(np.diff(times) <= 0)
|
||||
or times[0] < round(timeline_start * 1e9) - 1_000_000
|
||||
or times[-1] > round(timeline_end * 1e9) + 1_000_000
|
||||
or not np.isfinite(cloud_points).all()
|
||||
or not np.isfinite(support_points).all()
|
||||
or not np.isfinite(centers).all()
|
||||
or not np.isfinite(half_sizes).all()
|
||||
or not np.isfinite(quaternions).all()
|
||||
or not np.isfinite(distances).all()
|
||||
or np.any(half_sizes <= 0)
|
||||
or np.any(np.linalg.norm(quaternions, axis=1) < 1e-6)
|
||||
or np.any(track_ids < 1)
|
||||
or np.any(distances <= 0)
|
||||
or np.any(support_counts < 2)
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion arrays are inconsistent")
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
raise SessionIntegrityError("tracked fusion arrays are unavailable") from exc
|
||||
|
||||
|
||||
def _validate_frames(path: Path, *, tracking: TrackingQualificationResult) -> None:
|
||||
count = 0
|
||||
previous = -math.inf
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
for frame_index, line in enumerate(stream):
|
||||
if not line or len(line) > MAX_FRAME_LINE_BYTES:
|
||||
raise SessionIntegrityError("tracked fusion frame line is outside bounds")
|
||||
value = json.loads(line)
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
objects = value.get("objects") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != FRAME_SCHEMA
|
||||
or value.get("frame_index") != frame_index
|
||||
or value.get("source_frame_index")
|
||||
!= tracking.source_start_frame_index + frame_index
|
||||
or not _finite(session_seconds)
|
||||
or float(session_seconds) <= previous
|
||||
or not isinstance(objects, list)
|
||||
or value.get("state")
|
||||
not in {"fused", "depth-unavailable-sync-gate"}
|
||||
or not isinstance(value.get("accepted_cuboids"), int)
|
||||
or value["accepted_cuboids"] < 0
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion frame metadata changed")
|
||||
accepted = 0
|
||||
for item in objects:
|
||||
if not isinstance(item, dict):
|
||||
raise SessionIntegrityError("tracked fusion object is invalid")
|
||||
status = item.get("cuboid_status")
|
||||
if status == "accepted-point-supported-oriented-p05-p95":
|
||||
accepted += 1
|
||||
if (
|
||||
not _finite(item.get("distance_smoothed_m"))
|
||||
or not isinstance(item.get("clustered_points"), int)
|
||||
or item["clustered_points"] < 2
|
||||
):
|
||||
raise SessionIntegrityError("accepted tracked fusion object is invalid")
|
||||
if accepted != value["accepted_cuboids"]:
|
||||
raise SessionIntegrityError("tracked fusion frame cuboid count changed")
|
||||
previous = float(session_seconds)
|
||||
count += 1
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("tracked fusion frame metadata is unavailable") from exc
|
||||
if count != tracking.frame_count:
|
||||
raise SessionIntegrityError("tracked fusion frame count changed")
|
||||
|
||||
|
||||
def _validate_offsets(
|
||||
offsets: np.ndarray[Any, np.dtype[np.int64]],
|
||||
total: int,
|
||||
maximum: int,
|
||||
) -> None:
|
||||
if (
|
||||
offsets[0] != 0
|
||||
or offsets[-1] != total
|
||||
or np.any(np.diff(offsets) < 0)
|
||||
or np.any(np.diff(offsets) > maximum)
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion offsets are inconsistent")
|
||||
|
||||
|
||||
def _read_labels(path: Path) -> list[str]:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, list) or any(
|
||||
not isinstance(item, str) or not 1 <= len(item) <= 256 for item in value
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion labels are invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _artifact_path(
|
||||
artifacts: tuple[dict[str, Any], ...],
|
||||
kind: str,
|
||||
root: Path,
|
||||
) -> Path:
|
||||
matches = [item for item in artifacts if item["kind"] == kind]
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"tracked fusion artifact is unavailable: {kind}")
|
||||
return root / str(matches[0]["path"])
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("tracked fusion JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("tracked fusion JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("tracked fusion JSON root is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("tracked fusion artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("tracked fusion artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode()
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("tracked fusion identity cannot be encoded") from exc
|
||||
|
||||
|
||||
def _finite(value: object) -> TypeGuard[int | float]:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
@@ -0,0 +1,364 @@
|
||||
"""Validation for immutable LAB E5 tracking-qualification results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
from k1link.sessions import SessionIntegrityError
|
||||
|
||||
from .jobs import CameraComputeJob, validate_camera_compute_job
|
||||
|
||||
RESULT_SCHEMA = "missioncore.e5-tracking-result/v1"
|
||||
IDENTITY_SCHEMA = "missioncore.e5-tracking-identity/v1"
|
||||
REPORT_SCHEMA = "missioncore.e5-tracking-report/v1"
|
||||
FRAME_SCHEMA = "missioncore.e5-tracking-frame/v1"
|
||||
MAX_JSON_BYTES = 64 * 1024 * 1024
|
||||
MAX_FRAME_LINE_BYTES = 1024 * 1024
|
||||
MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
|
||||
_SAFE_RESULT_ID = re.compile(r"^e5-tracking-[a-f0-9]{64}$")
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackingQualificationArtifact:
|
||||
kind: str
|
||||
path: Path
|
||||
media_type: str
|
||||
byte_length: int
|
||||
sha256: str
|
||||
schema_version: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackingQualificationResult:
|
||||
result_id: str
|
||||
result_root: Path
|
||||
job: CameraComputeJob
|
||||
frame_count: int
|
||||
source_start_frame_index: int
|
||||
source_end_frame_index: int
|
||||
timeline_start_seconds: float
|
||||
timeline_end_seconds: float
|
||||
artifacts: tuple[TrackingQualificationArtifact, ...]
|
||||
|
||||
def artifact(self, kind: str) -> TrackingQualificationArtifact:
|
||||
matches = tuple(item for item in self.artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"tracking artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def validate_tracking_qualification_result(
|
||||
job_root: Path,
|
||||
result_root: Path,
|
||||
) -> TrackingQualificationResult:
|
||||
job = validate_camera_compute_job(job_root)
|
||||
root = result_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_RESULT_ID.fullmatch(root.name) is None:
|
||||
raise SessionIntegrityError("tracking result root is invalid")
|
||||
result = _read_object(root / "result.json", root)
|
||||
identity = result.get("identity")
|
||||
identity_sha256 = result.get("identity_sha256")
|
||||
if (
|
||||
result.get("schema_version") != RESULT_SCHEMA
|
||||
or result.get("result_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"e5-tracking-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
or result.get("job_id") != job.job_id
|
||||
or result.get("input_sha256") != job.input_sha256
|
||||
or result.get("session_id") != job.session_id
|
||||
or result.get("source_id") != job.source_id
|
||||
or result.get("timestamp_basis") != "session-time-seconds"
|
||||
or result.get("ground_truth") is not False
|
||||
or result.get("publication_scope") != "qualification-clip-only"
|
||||
or identity.get("job_id") != job.job_id
|
||||
or identity.get("input_sha256") != job.input_sha256
|
||||
):
|
||||
raise SessionIntegrityError("tracking result identity is inconsistent")
|
||||
clip = identity.get("clip")
|
||||
if not isinstance(clip, dict):
|
||||
raise SessionIntegrityError("tracking clip identity is unavailable")
|
||||
frame_count = clip.get("clip_frame_count")
|
||||
start_index = clip.get("source_start_frame_index")
|
||||
end_index = clip.get("source_end_frame_index")
|
||||
timeline_start = clip.get("timeline_start_seconds")
|
||||
timeline_end = clip.get("timeline_end_seconds")
|
||||
if (
|
||||
not isinstance(frame_count, int)
|
||||
or isinstance(frame_count, bool)
|
||||
or frame_count < 2
|
||||
or not isinstance(start_index, int)
|
||||
or isinstance(start_index, bool)
|
||||
or not isinstance(end_index, int)
|
||||
or isinstance(end_index, bool)
|
||||
or start_index < 0
|
||||
or end_index - start_index + 1 != frame_count
|
||||
or end_index >= job.segment_count
|
||||
or not _finite_number(timeline_start)
|
||||
or not _finite_number(timeline_end)
|
||||
or float(timeline_start) < job.timeline_start_seconds
|
||||
or float(timeline_end) > job.timeline_end_seconds
|
||||
or float(timeline_end) <= float(timeline_start)
|
||||
or result.get("frames_processed") != frame_count
|
||||
):
|
||||
raise SessionIntegrityError("tracking clip identity is invalid")
|
||||
artifacts = _validate_artifacts(root, result.get("artifacts"))
|
||||
report = _read_object(_artifact(artifacts, "tracking-run-report").path, root)
|
||||
metrics = report.get("metrics")
|
||||
report_input = report.get("input")
|
||||
if (
|
||||
report.get("schema_version") != REPORT_SCHEMA
|
||||
or report.get("state") != "published-qualification"
|
||||
or report.get("result_id") != root.name
|
||||
or report.get("ground_truth") is not False
|
||||
or not isinstance(metrics, dict)
|
||||
or metrics.get("frames_expected") != frame_count
|
||||
or metrics.get("frames_processed") != frame_count
|
||||
or metrics.get("frames_failed") != 0
|
||||
or metrics.get("frames_skipped") != 0
|
||||
or not isinstance(report_input, dict)
|
||||
or report_input.get("job_id") != job.job_id
|
||||
or report_input.get("input_sha256") != job.input_sha256
|
||||
or report_input.get("source_start_frame_index") != start_index
|
||||
or report_input.get("source_end_frame_index") != end_index
|
||||
):
|
||||
raise SessionIntegrityError("tracking report is inconsistent")
|
||||
_validate_frames(
|
||||
_artifact(artifacts, "tracking-frame-metadata"),
|
||||
frame_count=frame_count,
|
||||
source_start=start_index,
|
||||
timeline_start=float(timeline_start),
|
||||
timeline_end=float(timeline_end),
|
||||
)
|
||||
return TrackingQualificationResult(
|
||||
result_id=root.name,
|
||||
result_root=root,
|
||||
job=job,
|
||||
frame_count=frame_count,
|
||||
source_start_frame_index=start_index,
|
||||
source_end_frame_index=end_index,
|
||||
timeline_start_seconds=float(timeline_start),
|
||||
timeline_end_seconds=float(timeline_end),
|
||||
artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
def _validate_artifacts(
|
||||
root: Path,
|
||||
raw: object,
|
||||
) -> tuple[TrackingQualificationArtifact, ...]:
|
||||
expected = {
|
||||
"tracking-overlay-video": ("tracking.mp4", "video/mp4", None),
|
||||
"tracking-frame-metadata": ("frames.jsonl", "application/x-ndjson", FRAME_SCHEMA),
|
||||
"worker-gpu-telemetry": ("gpu-telemetry.jsonl", "application/x-ndjson", None),
|
||||
"tracking-contact-sheet": ("contact-sheet.png", "image/png", None),
|
||||
"tracking-run-report": ("run-report.json", "application/json", REPORT_SCHEMA),
|
||||
}
|
||||
if not isinstance(raw, list) or len(raw) != len(expected):
|
||||
raise SessionIntegrityError("tracking artifact set is invalid")
|
||||
artifacts: list[TrackingQualificationArtifact] = []
|
||||
seen: set[str] = set()
|
||||
for value in raw:
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("tracking artifact descriptor is invalid")
|
||||
kind = value.get("kind")
|
||||
if not isinstance(kind, str) or kind in seen or kind not in expected:
|
||||
raise SessionIntegrityError("tracking artifact kind is invalid")
|
||||
seen.add(kind)
|
||||
expected_path, expected_media_type, expected_schema = expected[kind]
|
||||
path = root / expected_path
|
||||
metadata = _confined_regular_file(path, root)
|
||||
byte_length = value.get("byte_length")
|
||||
digest = value.get("sha256")
|
||||
if (
|
||||
value.get("path") != expected_path
|
||||
or value.get("media_type") != expected_media_type
|
||||
or value.get("schema_version") != expected_schema
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or not 0 < byte_length <= MAX_SAFE_INTEGER
|
||||
or metadata.st_size != byte_length
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or _sha256_file(path) != digest
|
||||
):
|
||||
raise SessionIntegrityError("tracking artifact identity changed")
|
||||
artifacts.append(
|
||||
TrackingQualificationArtifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
media_type=expected_media_type,
|
||||
byte_length=byte_length,
|
||||
sha256=digest,
|
||||
schema_version=expected_schema,
|
||||
)
|
||||
)
|
||||
return tuple(artifacts)
|
||||
|
||||
|
||||
def _validate_frames(
|
||||
artifact: TrackingQualificationArtifact,
|
||||
*,
|
||||
frame_count: int,
|
||||
source_start: int,
|
||||
timeline_start: float,
|
||||
timeline_end: float,
|
||||
) -> None:
|
||||
previous = -math.inf
|
||||
count = 0
|
||||
try:
|
||||
with artifact.path.open("rb") as stream:
|
||||
for frame_index, line in enumerate(stream):
|
||||
if not line or len(line) > MAX_FRAME_LINE_BYTES:
|
||||
raise SessionIntegrityError("tracking frame line is outside bounds")
|
||||
value = json.loads(line)
|
||||
session_seconds = value.get("session_seconds") if isinstance(value, dict) else None
|
||||
detections = value.get("detections") if isinstance(value, dict) else None
|
||||
tracks = value.get("tracks") if isinstance(value, dict) else None
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or value.get("schema_version") != FRAME_SCHEMA
|
||||
or value.get("frame_index") != frame_index
|
||||
or value.get("sequence") != frame_index + 1
|
||||
or value.get("source_frame_index") != source_start + frame_index
|
||||
or value.get("source_sequence") != source_start + frame_index + 1
|
||||
or not _finite_number(session_seconds)
|
||||
or float(session_seconds) <= previous
|
||||
or float(session_seconds) < timeline_start - 0.001
|
||||
or float(session_seconds) > timeline_end + 0.001
|
||||
or not isinstance(detections, list)
|
||||
or not isinstance(tracks, list)
|
||||
):
|
||||
raise SessionIntegrityError("tracking frame metadata is inconsistent")
|
||||
_validate_observations(detections, tracks)
|
||||
previous = float(session_seconds)
|
||||
count += 1
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("tracking frame metadata is unavailable") from exc
|
||||
if count != frame_count:
|
||||
raise SessionIntegrityError("tracking frame metadata count changed")
|
||||
|
||||
|
||||
def _validate_observations(detections: list[object], tracks: list[object]) -> None:
|
||||
for detection in detections:
|
||||
if not isinstance(detection, dict):
|
||||
raise SessionIntegrityError("tracking detection is invalid")
|
||||
_validate_box(detection.get("bbox_xyxy"))
|
||||
if (
|
||||
not isinstance(detection.get("class_id"), int)
|
||||
or not isinstance(detection.get("label"), str)
|
||||
or not _finite_number(detection.get("score"))
|
||||
or not 0 <= float(detection["score"]) <= 1
|
||||
):
|
||||
raise SessionIntegrityError("tracking detection fields are invalid")
|
||||
track_ids: set[int] = set()
|
||||
for track in tracks:
|
||||
if not isinstance(track, dict):
|
||||
raise SessionIntegrityError("tracking observation is invalid")
|
||||
_validate_box(track.get("bbox_xyxy"))
|
||||
track_id = track.get("track_id")
|
||||
if (
|
||||
not isinstance(track_id, int)
|
||||
or isinstance(track_id, bool)
|
||||
or track_id < 1
|
||||
or track_id in track_ids
|
||||
or not isinstance(track.get("class_id"), int)
|
||||
or not isinstance(track.get("label"), str)
|
||||
or not _finite_number(track.get("score"))
|
||||
or not isinstance(track.get("hits"), int)
|
||||
or track["hits"] < 1
|
||||
):
|
||||
raise SessionIntegrityError("tracking observation fields are invalid")
|
||||
track_ids.add(track_id)
|
||||
|
||||
|
||||
def _validate_box(value: object) -> None:
|
||||
if (
|
||||
not isinstance(value, list)
|
||||
or len(value) != 4
|
||||
or not all(_finite_number(item) for item in value)
|
||||
or not 0 <= float(value[0]) < float(value[2]) <= 800
|
||||
or not 0 <= float(value[1]) < float(value[3]) <= 600
|
||||
):
|
||||
raise SessionIntegrityError("tracking box is invalid")
|
||||
|
||||
|
||||
def _artifact(
|
||||
artifacts: tuple[TrackingQualificationArtifact, ...],
|
||||
kind: str,
|
||||
) -> TrackingQualificationArtifact:
|
||||
matches = tuple(item for item in artifacts if item.kind == kind)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError(f"tracking artifact is unavailable: {kind}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def _read_object(path: Path, root: Path) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= MAX_JSON_BYTES:
|
||||
raise SessionIntegrityError("tracking JSON is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError("tracking JSON is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise SessionIntegrityError("tracking JSON root is invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("tracking artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise SessionIntegrityError("tracking artifact is not confined")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("tracking identity is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _finite_number(value: object) -> TypeGuard[int | float]:
|
||||
return (
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
)
|
||||
@@ -1,15 +1,51 @@
|
||||
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||
CalibratedOverlayExperiment,
|
||||
CalibratedOverlayExperimentError,
|
||||
run_calibrated_overlay_experiment,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
CalibratedProjectionError,
|
||||
Kb4ProjectionProfile,
|
||||
ProjectedPointCloud,
|
||||
depth_colors,
|
||||
map_points_to_lidar,
|
||||
project_map_points_kb4,
|
||||
quaternion_xyzw_to_rotation_matrix,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
StreamSummary,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.valid_fov import (
|
||||
DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
K1ValidFovMask,
|
||||
K1ValidFovMaskError,
|
||||
prepare_k1_valid_fov_mask,
|
||||
validate_k1_valid_fov_mask,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CalibratedOverlayExperiment",
|
||||
"CalibratedOverlayExperimentError",
|
||||
"CalibratedProjectionError",
|
||||
"DEFAULT_EDGE_MARGIN_PIXELS",
|
||||
"DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES",
|
||||
"Kb4ProjectionProfile",
|
||||
"K1ValidFovMask",
|
||||
"K1ValidFovMaskError",
|
||||
"MAX_STREAM_SUMMARY_PAYLOAD_BYTES",
|
||||
"ProjectedPointCloud",
|
||||
"StreamSummary",
|
||||
"depth_colors",
|
||||
"map_points_to_lidar",
|
||||
"project_map_points_kb4",
|
||||
"prepare_k1_valid_fov_mask",
|
||||
"quaternion_xyzw_to_rotation_matrix",
|
||||
"run_calibrated_overlay_experiment",
|
||||
"summarize_mqtt_streams",
|
||||
"validate_k1_valid_fov_mask",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,950 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import rerun as rr
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
ProjectedPointCloud,
|
||||
depth_colors,
|
||||
project_map_points_kb4,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
K1FactoryCalibration,
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
read_capture_clock_envelope,
|
||||
read_capture_clock_origin,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
LioPointCloudFrame,
|
||||
LioPoseFrame,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||
from k1link.sessions import inspect_recorded_media_epoch
|
||||
|
||||
CALIBRATED_OVERLAY_SCHEMA = "missioncore.k1-calibrated-overlay-experiment/v1"
|
||||
CALIBRATED_OVERLAY_SUFFIX = "k1_calibrated_overlay"
|
||||
MAX_JSON_BYTES = 2 * 1024 * 1024
|
||||
MAX_INDEX_LINE_BYTES = 64 * 1024
|
||||
MAX_OPERATOR_NOTES_BYTES = 64 * 1024
|
||||
FFMPEG_TIMEOUT_SECONDS = 180.0
|
||||
MAX_SYNC_DELTA_SECONDS = 0.25
|
||||
_READ_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
RgbImage = npt.NDArray[np.uint8]
|
||||
|
||||
|
||||
class CalibratedOverlayExperimentError(RuntimeError):
|
||||
"""Raised when a recorded calibrated-overlay experiment cannot be sealed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibratedOverlayExperiment:
|
||||
experiment_id: str
|
||||
experiment_root: Path
|
||||
manifest_path: Path
|
||||
rerun_path: Path
|
||||
mosaic_path: Path
|
||||
frame_count: int
|
||||
source_id: str
|
||||
calibration_content_identity: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CameraFrameAnchor:
|
||||
requested_video_offset_seconds: float
|
||||
sequence: int
|
||||
session_time_seconds: float
|
||||
host_epoch_ns: int
|
||||
host_monotonic_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LidarSample:
|
||||
camera: _CameraFrameAnchor
|
||||
point_session_time_seconds: float
|
||||
pose_session_time_seconds: float
|
||||
point_frame: LioPointCloudFrame
|
||||
pose_frame: LioPoseFrame
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RenderedFrame:
|
||||
camera: _CameraFrameAnchor
|
||||
lidar: _LidarSample
|
||||
image_rgb: RgbImage
|
||||
overlay_rgb: RgbImage
|
||||
projection: ProjectedPointCloud
|
||||
colors_rgb: RgbImage
|
||||
camera_artifact_name: str
|
||||
artifact_name: str
|
||||
|
||||
|
||||
def run_calibrated_overlay_experiment(
|
||||
*,
|
||||
session_root: Path,
|
||||
calibration_snapshot_root: Path,
|
||||
source_id: str,
|
||||
video_offsets_seconds: tuple[float, ...],
|
||||
output_root: Path,
|
||||
ffmpeg_path: Path,
|
||||
temporal_offset_seconds: float = 0.0,
|
||||
) -> CalibratedOverlayExperiment:
|
||||
"""Seal a derived LiDAR→KB4→camera diagnostic without changing native evidence."""
|
||||
|
||||
started_monotonic_ns = time.monotonic_ns()
|
||||
session = session_root.expanduser().resolve(strict=True)
|
||||
if not session.is_dir() or session.name in {"", ".", ".."}:
|
||||
raise CalibratedOverlayExperimentError("session root is invalid")
|
||||
if not video_offsets_seconds:
|
||||
raise CalibratedOverlayExperimentError("at least one video offset is required")
|
||||
if len(video_offsets_seconds) > 16:
|
||||
raise CalibratedOverlayExperimentError("at most sixteen diagnostic frames are allowed")
|
||||
offsets = tuple(sorted(video_offsets_seconds))
|
||||
if len(set(offsets)) != len(offsets) or any(
|
||||
not math.isfinite(value) or value < 0.0 for value in offsets
|
||||
):
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"video offsets must be unique finite non-negative seconds"
|
||||
)
|
||||
if not math.isfinite(temporal_offset_seconds) or abs(temporal_offset_seconds) > 5.0:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"temporal offset must be finite and inside the reviewed ±5 second window"
|
||||
)
|
||||
|
||||
calibration, calibration_identity = _load_calibration_snapshot(
|
||||
calibration_snapshot_root
|
||||
)
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, source_id)
|
||||
capture_root = session / "captures" / "mqtt_live"
|
||||
raw_path = capture_root / "mqtt.raw.k1mqtt"
|
||||
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||
summary_path = capture_root / "mqtt.summary.json"
|
||||
clock_origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||
clock_summary = _read_json(summary_path, MAX_JSON_BYTES)
|
||||
clock_name = _required_text(
|
||||
_required_mapping(clock_summary.get("artifacts"), "artifacts").get(
|
||||
"capture_clock"
|
||||
),
|
||||
"capture clock filename",
|
||||
)
|
||||
clock_path = capture_root / clock_name
|
||||
capture_clock = read_capture_clock_envelope(clock_path)
|
||||
capture_origin = read_capture_clock_origin(clock_origin_path)
|
||||
if (
|
||||
capture_clock.started_at_epoch_ns != capture_origin.started_at_epoch_ns
|
||||
or capture_clock.started_monotonic_ns != capture_origin.started_monotonic_ns
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("MQTT capture clock does not match its origin")
|
||||
if not raw_path.is_file() or not metadata_path.is_file():
|
||||
raise CalibratedOverlayExperimentError("sealed MQTT evidence is incomplete")
|
||||
|
||||
camera_epoch_root = session / "media" / source_id / "epoch-1"
|
||||
inspected_epoch = inspect_recorded_media_epoch(
|
||||
camera_epoch_root,
|
||||
expected_source_name=source_id,
|
||||
origin_epoch_ns=capture_origin.started_at_epoch_ns,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
)
|
||||
camera_duration_seconds = (
|
||||
inspected_epoch.timeline_end_seconds - inspected_epoch.timeline_start_seconds
|
||||
)
|
||||
if offsets[-1] >= camera_duration_seconds:
|
||||
raise CalibratedOverlayExperimentError("a video offset is outside the camera epoch")
|
||||
camera_anchors = _select_camera_anchors(
|
||||
camera_epoch_root / "index.jsonl",
|
||||
expected_count=len(inspected_epoch.segments),
|
||||
offsets_seconds=offsets,
|
||||
timeline_start_seconds=inspected_epoch.timeline_start_seconds,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
)
|
||||
camera_images = _extract_camera_frames(
|
||||
ffmpeg_path.expanduser().resolve(strict=True),
|
||||
init_path=inspected_epoch.init_path,
|
||||
segment_paths=tuple(segment.path for segment in inspected_epoch.segments),
|
||||
frame_sequences=tuple(anchor.sequence for anchor in camera_anchors),
|
||||
width=profile.width,
|
||||
height=profile.height,
|
||||
)
|
||||
lidar_samples = _select_lidar_samples(
|
||||
raw_path,
|
||||
camera_anchors,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
|
||||
created_at = datetime.now(UTC)
|
||||
experiment_uuid = uuid4().hex
|
||||
experiment_id = (
|
||||
f"{created_at.strftime('%Y%m%dT%H%M%SZ')}_{CALIBRATED_OVERLAY_SUFFIX}_"
|
||||
f"{experiment_uuid[:12]}"
|
||||
)
|
||||
private_root = output_root.expanduser().resolve() / "private" / "perception-experiments"
|
||||
private_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
_chmod_private(private_root)
|
||||
final_root = private_root / experiment_id
|
||||
staging_root = private_root / f".{experiment_id}.incomplete"
|
||||
staging_root.mkdir(mode=0o700, exist_ok=False)
|
||||
published = False
|
||||
try:
|
||||
rendered: list[_RenderedFrame] = []
|
||||
for image_rgb, camera_anchor, lidar_sample in zip(
|
||||
camera_images,
|
||||
camera_anchors,
|
||||
lidar_samples,
|
||||
strict=True,
|
||||
):
|
||||
positions = np.asarray(
|
||||
[
|
||||
point.scaled_xyz(lidar_sample.point_frame.header.scaler)
|
||||
for point in lidar_sample.point_frame.points
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
projection = project_map_points_kb4(
|
||||
positions,
|
||||
position_map_xyz=lidar_sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=(
|
||||
lidar_sample.pose_frame.orientation_xyzw
|
||||
),
|
||||
profile=profile,
|
||||
)
|
||||
if projection.projected_point_count == 0:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {camera_anchor.sequence} has no projected LiDAR points"
|
||||
)
|
||||
colors = depth_colors(projection.depths_m)
|
||||
overlay = _render_overlay(
|
||||
image_rgb,
|
||||
projection,
|
||||
colors,
|
||||
camera_anchor=camera_anchor,
|
||||
lidar_sample=lidar_sample,
|
||||
)
|
||||
artifact_name = (
|
||||
f"frame-{camera_anchor.sequence:06d}-"
|
||||
f"session-{round(camera_anchor.session_time_seconds * 1000):09d}ms.png"
|
||||
)
|
||||
camera_artifact_name = (
|
||||
f"camera-{camera_anchor.sequence:06d}-"
|
||||
f"session-{round(camera_anchor.session_time_seconds * 1000):09d}ms.png"
|
||||
)
|
||||
_write_image_exclusive(staging_root / camera_artifact_name, image_rgb)
|
||||
_write_image_exclusive(staging_root / artifact_name, overlay)
|
||||
rendered.append(
|
||||
_RenderedFrame(
|
||||
camera=camera_anchor,
|
||||
lidar=lidar_sample,
|
||||
image_rgb=image_rgb,
|
||||
overlay_rgb=overlay,
|
||||
projection=projection,
|
||||
colors_rgb=colors,
|
||||
camera_artifact_name=camera_artifact_name,
|
||||
artifact_name=artifact_name,
|
||||
)
|
||||
)
|
||||
|
||||
mosaic_path = staging_root / "overlay-mosaic.png"
|
||||
_write_image_exclusive(mosaic_path, _mosaic(tuple(rendered)))
|
||||
rerun_path = staging_root / "diagnostic.rrd"
|
||||
_write_rerun_diagnostic(
|
||||
rerun_path,
|
||||
experiment_id=experiment_id,
|
||||
rendered=tuple(rendered),
|
||||
calibration_identity=calibration_identity,
|
||||
profile=profile,
|
||||
)
|
||||
operator_notes = _operator_notes(
|
||||
experiment_id=experiment_id,
|
||||
session_id=session.name,
|
||||
source_id=source_id,
|
||||
calibration_identity=calibration_identity,
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
if len(operator_notes) > MAX_OPERATOR_NOTES_BYTES:
|
||||
raise CalibratedOverlayExperimentError("operator notes exceed their byte bound")
|
||||
_write_exclusive(staging_root / "operator-notes.md", operator_notes)
|
||||
|
||||
input_identity = {
|
||||
"session_id": session.name,
|
||||
"source_id": source_id,
|
||||
"raw_mqtt_sha256": _verified_raw_sha256(raw_path, clock_summary),
|
||||
"camera_stream_sha256": _required_text(
|
||||
_read_json(camera_epoch_root / "summary.json", MAX_JSON_BYTES).get(
|
||||
"stream_sha256"
|
||||
),
|
||||
"camera stream sha256",
|
||||
),
|
||||
"calibration_content_identity_sha256": calibration_identity,
|
||||
"video_offsets_seconds": list(offsets),
|
||||
"temporal_offset_seconds": temporal_offset_seconds,
|
||||
"projection_contract": "map→inverse(T_map_from_lidar)→T_camera_1_from_lidar→KB4",
|
||||
}
|
||||
generation_sha256 = hashlib.sha256(_canonical_json(input_identity)).hexdigest()
|
||||
frame_documents = [_frame_document(item) for item in rendered]
|
||||
output_names = [
|
||||
*(item.camera_artifact_name for item in rendered),
|
||||
*(item.artifact_name for item in rendered),
|
||||
mosaic_path.name,
|
||||
rerun_path.name,
|
||||
"operator-notes.md",
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": CALIBRATED_OVERLAY_SCHEMA,
|
||||
"experiment_id": experiment_id,
|
||||
"created_at_utc": created_at.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"completed_monotonic_ns": time.monotonic_ns(),
|
||||
"elapsed_seconds": round(
|
||||
(time.monotonic_ns() - started_monotonic_ns) / 1_000_000_000,
|
||||
6,
|
||||
),
|
||||
"classification": "private-derived-calibration-diagnostic",
|
||||
"generation_sha256": generation_sha256,
|
||||
"input": input_identity,
|
||||
"camera_epoch": {
|
||||
"codec_epoch": 1,
|
||||
"resolution": [profile.width, profile.height],
|
||||
"timeline_start_seconds": inspected_epoch.timeline_start_seconds,
|
||||
"timeline_end_seconds": inspected_epoch.timeline_end_seconds,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"geometry": {
|
||||
"calibration_slot": profile.calibration_slot,
|
||||
"camera_model": "kb4",
|
||||
"transform_notation": "T_destination_from_source",
|
||||
"point_frame": "map",
|
||||
"pose_interpretation": "T_map_from_lidar",
|
||||
"behind_camera_policy": "reject-z-less-than-or-equal-to-zero",
|
||||
"occlusion_policy": "diagnostic-draw-far-to-near",
|
||||
},
|
||||
"frames": frame_documents,
|
||||
"outputs": [_artifact_document(staging_root / name) for name in output_names],
|
||||
"acceptance": {
|
||||
"geometry_input_subgate": "accepted",
|
||||
"diagnostic_layer": "generated",
|
||||
"measured_reprojection": "pending-static-landmark-correspondences",
|
||||
"temporal_calibration": "unverified-host-arrival-only",
|
||||
"p0": "not-yet-accepted",
|
||||
},
|
||||
}
|
||||
_write_exclusive(
|
||||
staging_root / "manifest.redacted.json",
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
+ b"\n",
|
||||
)
|
||||
_fsync_directory(staging_root)
|
||||
os.rename(staging_root, final_root)
|
||||
_fsync_directory(private_root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging_root.exists():
|
||||
shutil.rmtree(staging_root)
|
||||
|
||||
return CalibratedOverlayExperiment(
|
||||
experiment_id=experiment_id,
|
||||
experiment_root=final_root,
|
||||
manifest_path=final_root / "manifest.redacted.json",
|
||||
rerun_path=final_root / "diagnostic.rrd",
|
||||
mosaic_path=final_root / "overlay-mosaic.png",
|
||||
frame_count=len(camera_anchors),
|
||||
source_id=source_id,
|
||||
calibration_content_identity=calibration_identity,
|
||||
)
|
||||
|
||||
|
||||
def _load_calibration_snapshot(
|
||||
snapshot_root: Path,
|
||||
) -> tuple[K1FactoryCalibration, str]:
|
||||
root = snapshot_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir():
|
||||
raise CalibratedOverlayExperimentError("calibration snapshot is not a directory")
|
||||
manifest = _read_json(root / "manifest.json", MAX_JSON_BYTES)
|
||||
if manifest.get("schema_version") != CALIBRATION_SNAPSHOT_MANIFEST_VERSION:
|
||||
raise CalibratedOverlayExperimentError("calibration snapshot schema is incompatible")
|
||||
camera_payload = _read_regular(root / "camera.yaml", 64 * 1024)
|
||||
extrinsic_payload = _read_regular(root / "extrinsic_camera_lidar.yaml", 64 * 1024)
|
||||
artifact_digests = {
|
||||
item.get("artifact_name"): item.get("sha256")
|
||||
for item in _required_list(manifest.get("artifacts"), "calibration artifacts")
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
expected = {
|
||||
"camera.yaml": hashlib.sha256(camera_payload).hexdigest(),
|
||||
"extrinsic_camera_lidar.yaml": hashlib.sha256(extrinsic_payload).hexdigest(),
|
||||
}
|
||||
if artifact_digests != expected:
|
||||
raise CalibratedOverlayExperimentError("calibration artifact identity changed")
|
||||
calibration = parse_k1_factory_calibration(camera_payload, extrinsic_payload)
|
||||
if manifest.get("normalized_calibration") != calibration.normalized_profile():
|
||||
raise CalibratedOverlayExperimentError("normalized calibration changed")
|
||||
identity = _required_sha256(
|
||||
manifest.get("content_identity_sha256"),
|
||||
"calibration content identity",
|
||||
)
|
||||
return calibration, identity
|
||||
|
||||
|
||||
def _select_camera_anchors(
|
||||
index_path: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
offsets_seconds: tuple[float, ...],
|
||||
timeline_start_seconds: float,
|
||||
origin_monotonic_ns: int,
|
||||
) -> tuple[_CameraFrameAnchor, ...]:
|
||||
targets = tuple(timeline_start_seconds + offset for offset in offsets_seconds)
|
||||
best: list[tuple[float, _CameraFrameAnchor] | None] = [None] * len(targets)
|
||||
with index_path.open("rb") as stream:
|
||||
for expected_sequence in range(1, expected_count + 1):
|
||||
line = stream.readline(MAX_INDEX_LINE_BYTES + 1)
|
||||
if not line or len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
|
||||
raise CalibratedOverlayExperimentError("camera index is incomplete or unbounded")
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"camera index contains invalid JSON"
|
||||
) from exc
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or entry.get("sequence") != expected_sequence
|
||||
or entry.get("kind") != "media"
|
||||
or entry.get("path") != f"segments/{expected_sequence}.m4s"
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("camera index sequence is inconsistent")
|
||||
host_monotonic_ns = entry.get("host_monotonic_ns")
|
||||
host_epoch_ns = entry.get("host_epoch_ns")
|
||||
if (
|
||||
not isinstance(host_monotonic_ns, int)
|
||||
or host_monotonic_ns < origin_monotonic_ns
|
||||
or not isinstance(host_epoch_ns, int)
|
||||
or host_epoch_ns < 0
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("camera index time is invalid")
|
||||
session_time = (host_monotonic_ns - origin_monotonic_ns) / 1_000_000_000
|
||||
for index, (offset, target) in enumerate(zip(offsets_seconds, targets, strict=True)):
|
||||
delta = abs(session_time - target)
|
||||
current = best[index]
|
||||
if current is None or delta < current[0]:
|
||||
best[index] = (
|
||||
delta,
|
||||
_CameraFrameAnchor(
|
||||
requested_video_offset_seconds=offset,
|
||||
sequence=expected_sequence,
|
||||
session_time_seconds=session_time,
|
||||
host_epoch_ns=host_epoch_ns,
|
||||
host_monotonic_ns=host_monotonic_ns,
|
||||
),
|
||||
)
|
||||
if stream.read(1):
|
||||
raise CalibratedOverlayExperimentError("camera index has undeclared rows")
|
||||
anchors_list: list[_CameraFrameAnchor] = []
|
||||
for item in best:
|
||||
if item is None:
|
||||
raise CalibratedOverlayExperimentError("camera offset has no frame")
|
||||
anchors_list.append(item[1])
|
||||
anchors = tuple(anchors_list)
|
||||
if len({item.sequence for item in anchors}) != len(anchors):
|
||||
raise CalibratedOverlayExperimentError("camera offsets do not select unique frames")
|
||||
return anchors
|
||||
|
||||
|
||||
def _extract_camera_frames(
|
||||
ffmpeg_path: Path,
|
||||
*,
|
||||
init_path: Path,
|
||||
segment_paths: tuple[Path, ...],
|
||||
frame_sequences: tuple[int, ...],
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[RgbImage, ...]:
|
||||
if not ffmpeg_path.is_file():
|
||||
raise CalibratedOverlayExperimentError("ffmpeg is unavailable")
|
||||
frame_indices = tuple(sequence - 1 for sequence in frame_sequences)
|
||||
select = "+".join(f"eq(n\\,{index})" for index in frame_indices)
|
||||
read_fd, write_fd = os.pipe()
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(ffmpeg_path),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
f"select={select}",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-frames:v",
|
||||
str(len(frame_indices)),
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"pipe:1",
|
||||
],
|
||||
stdin=read_fd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
)
|
||||
os.close(read_fd)
|
||||
feeder_errors: list[BaseException] = []
|
||||
|
||||
def feed() -> None:
|
||||
try:
|
||||
with os.fdopen(write_fd, "wb", buffering=0) as sink:
|
||||
for path in (init_path, *segment_paths):
|
||||
with path.open("rb") as source:
|
||||
shutil.copyfileobj(source, sink, length=_READ_CHUNK_BYTES)
|
||||
except BrokenPipeError:
|
||||
return
|
||||
except BaseException as exc: # pragma: no cover - defensive child-pipe boundary
|
||||
feeder_errors.append(exc)
|
||||
|
||||
feeder = threading.Thread(target=feed, name="k1-calibrated-overlay-ffmpeg", daemon=True)
|
||||
feeder.start()
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=FFMPEG_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
raise CalibratedOverlayExperimentError("ffmpeg frame extraction timed out") from exc
|
||||
feeder.join(timeout=5.0)
|
||||
if feeder.is_alive():
|
||||
raise CalibratedOverlayExperimentError("camera archive feeder did not stop")
|
||||
if feeder_errors:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"camera archive streaming failed"
|
||||
) from feeder_errors[0]
|
||||
if process.returncode != 0:
|
||||
message = stderr.decode("utf-8", errors="replace").strip()[-1000:]
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"ffmpeg rejected the sealed camera epoch: {message}"
|
||||
)
|
||||
frame_bytes = width * height * 3
|
||||
if len(stdout) != frame_bytes * len(frame_indices):
|
||||
raise CalibratedOverlayExperimentError("ffmpeg returned an unexpected frame count")
|
||||
return tuple(
|
||||
np.frombuffer(stdout, dtype=np.uint8, count=frame_bytes, offset=index * frame_bytes)
|
||||
.reshape((height, width, 3))
|
||||
.copy()
|
||||
for index in range(len(frame_indices))
|
||||
)
|
||||
|
||||
|
||||
def _select_lidar_samples(
|
||||
raw_path: Path,
|
||||
camera_anchors: tuple[_CameraFrameAnchor, ...],
|
||||
*,
|
||||
origin_monotonic_ns: int,
|
||||
temporal_offset_seconds: float,
|
||||
) -> tuple[_LidarSample, ...]:
|
||||
targets = tuple(
|
||||
anchor.session_time_seconds + temporal_offset_seconds for anchor in camera_anchors
|
||||
)
|
||||
best_points: list[tuple[float, float, LioPointCloudFrame] | None] = [None] * len(targets)
|
||||
nearby_poses: list[list[tuple[float, LioPoseFrame]]] = [[] for _ in targets]
|
||||
last_target = max(targets)
|
||||
first_target = min(targets)
|
||||
for message in iter_replay_messages(raw_path):
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if monotonic_ns is None:
|
||||
raise CalibratedOverlayExperimentError("MQTT metadata has no monotonic time")
|
||||
session_time = (monotonic_ns - origin_monotonic_ns) / 1_000_000_000
|
||||
if session_time < first_target - 1.0:
|
||||
continue
|
||||
if session_time > last_target + 1.0:
|
||||
break
|
||||
if message.topic.endswith("/lio_pcl"):
|
||||
for index, target in enumerate(targets):
|
||||
delta = abs(session_time - target)
|
||||
current = best_points[index]
|
||||
if delta <= MAX_SYNC_DELTA_SECONDS and (current is None or delta < current[0]):
|
||||
best_points[index] = (delta, session_time, decode_lio_pcl(message.payload))
|
||||
elif message.topic.endswith("/lio_pose"):
|
||||
for index, target in enumerate(targets):
|
||||
if abs(session_time - target) <= 0.5:
|
||||
nearby_poses[index].append((session_time, decode_lio_pose(message.payload)))
|
||||
|
||||
samples: list[_LidarSample] = []
|
||||
for index, anchor in enumerate(camera_anchors):
|
||||
point = best_points[index]
|
||||
if point is None or not nearby_poses[index]:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {anchor.sequence} has no temporally compatible LiDAR sample"
|
||||
)
|
||||
_delta, point_time, point_frame = point
|
||||
pose_time, pose_frame = min(
|
||||
nearby_poses[index],
|
||||
key=lambda item: abs(item[0] - point_time),
|
||||
)
|
||||
if abs(pose_time - point_time) > MAX_SYNC_DELTA_SECONDS:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {anchor.sequence} has no compatible pose"
|
||||
)
|
||||
samples.append(
|
||||
_LidarSample(
|
||||
camera=anchor,
|
||||
point_session_time_seconds=point_time,
|
||||
pose_session_time_seconds=pose_time,
|
||||
point_frame=point_frame,
|
||||
pose_frame=pose_frame,
|
||||
)
|
||||
)
|
||||
return tuple(samples)
|
||||
|
||||
|
||||
def _render_overlay(
|
||||
image_rgb: RgbImage,
|
||||
projection: ProjectedPointCloud,
|
||||
colors_rgb: RgbImage,
|
||||
*,
|
||||
camera_anchor: _CameraFrameAnchor,
|
||||
lidar_sample: _LidarSample,
|
||||
) -> RgbImage:
|
||||
image = Image.fromarray(image_rgb, mode="RGB").convert("RGBA")
|
||||
draw = ImageDraw.Draw(image, "RGBA")
|
||||
order = np.argsort(projection.depths_m)[::-1]
|
||||
for index in order:
|
||||
u, v = projection.pixels_xy[index]
|
||||
red, green, blue = (int(value) for value in colors_rgb[index])
|
||||
draw.ellipse(
|
||||
(u - 2.0, v - 2.0, u + 2.0, v + 2.0),
|
||||
fill=(red, green, blue, 205),
|
||||
)
|
||||
point_delta_ms = (
|
||||
lidar_sample.point_session_time_seconds - camera_anchor.session_time_seconds
|
||||
) * 1000.0
|
||||
draw.rectangle((0, 0, image.width, 34), fill=(0, 0, 0, 190))
|
||||
draw.text(
|
||||
(8, 9),
|
||||
(
|
||||
f"session={camera_anchor.session_time_seconds:.3f}s "
|
||||
f"LiDAR-camera={point_delta_ms:+.1f}ms "
|
||||
f"projected={projection.projected_point_count}/"
|
||||
f"{projection.source_point_count}"
|
||||
),
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
return np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
|
||||
def _mosaic(rendered: tuple[_RenderedFrame, ...]) -> RgbImage:
|
||||
columns = 2 if len(rendered) > 1 else 1
|
||||
rows = math.ceil(len(rendered) / columns)
|
||||
width = rendered[0].overlay_rgb.shape[1]
|
||||
height = rendered[0].overlay_rgb.shape[0]
|
||||
canvas = Image.new("RGB", (columns * width, rows * height), "black")
|
||||
for index, item in enumerate(rendered):
|
||||
canvas.paste(
|
||||
Image.fromarray(item.overlay_rgb, mode="RGB"),
|
||||
((index % columns) * width, (index // columns) * height),
|
||||
)
|
||||
return np.asarray(canvas, dtype=np.uint8)
|
||||
|
||||
|
||||
def _write_rerun_diagnostic(
|
||||
path: Path,
|
||||
*,
|
||||
experiment_id: str,
|
||||
rendered: tuple[_RenderedFrame, ...],
|
||||
calibration_identity: str,
|
||||
profile: Kb4ProjectionProfile,
|
||||
) -> None:
|
||||
recording = rr.RecordingStream(
|
||||
"nodedc_mission_core_k1_calibration",
|
||||
recording_id=experiment_id,
|
||||
)
|
||||
recording.set_sinks(rr.FileSink(path, write_footer=True))
|
||||
try:
|
||||
recording.log(
|
||||
"k1/calibrated/contract",
|
||||
rr.TextDocument(
|
||||
"\n".join(
|
||||
(
|
||||
"K1 factory-calibrated diagnostic",
|
||||
f"source={profile.source_id}",
|
||||
f"slot={profile.calibration_slot}",
|
||||
f"calibration={calibration_identity}",
|
||||
"projection=map→inverse(T_map_from_lidar)→T_camera_from_lidar→KB4",
|
||||
"timing=host-arrival-best-effort; P0 temporal acceptance pending",
|
||||
)
|
||||
)
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
for item in rendered:
|
||||
session_time_ns = round(item.camera.session_time_seconds * 1_000_000_000)
|
||||
recording.set_time(
|
||||
"session_time",
|
||||
duration=np.timedelta64(session_time_ns, "ns"),
|
||||
)
|
||||
recording.log("k1/calibrated/camera", rr.Image(item.image_rgb))
|
||||
recording.log(
|
||||
"k1/calibrated/camera/projected_lidar",
|
||||
rr.Points2D(
|
||||
item.projection.pixels_xy.astype(np.float32),
|
||||
colors=item.colors_rgb,
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
finally:
|
||||
path.chmod(0o600)
|
||||
_fsync_file(path)
|
||||
|
||||
|
||||
def _frame_document(item: _RenderedFrame) -> dict[str, object]:
|
||||
depth = item.projection.depths_m
|
||||
return {
|
||||
"camera_sequence": item.camera.sequence,
|
||||
"requested_video_offset_seconds": item.camera.requested_video_offset_seconds,
|
||||
"camera_session_time_seconds": item.camera.session_time_seconds,
|
||||
"point_session_time_seconds": item.lidar.point_session_time_seconds,
|
||||
"pose_session_time_seconds": item.lidar.pose_session_time_seconds,
|
||||
"point_minus_camera_ms": round(
|
||||
(
|
||||
item.lidar.point_session_time_seconds
|
||||
- item.camera.session_time_seconds
|
||||
)
|
||||
* 1000.0,
|
||||
6,
|
||||
),
|
||||
"pose_minus_point_ms": round(
|
||||
(
|
||||
item.lidar.pose_session_time_seconds
|
||||
- item.lidar.point_session_time_seconds
|
||||
)
|
||||
* 1000.0,
|
||||
6,
|
||||
),
|
||||
"source_points": item.projection.source_point_count,
|
||||
"camera_front_points": item.projection.camera_front_point_count,
|
||||
"projected_points": item.projection.projected_point_count,
|
||||
"projected_fraction": round(
|
||||
item.projection.projected_point_count / item.projection.source_point_count,
|
||||
9,
|
||||
),
|
||||
"depth_m": {
|
||||
"p05": float(np.percentile(depth, 5.0)),
|
||||
"median": float(np.median(depth)),
|
||||
"p95": float(np.percentile(depth, 95.0)),
|
||||
},
|
||||
"artifact": item.artifact_name,
|
||||
"camera_artifact": item.camera_artifact_name,
|
||||
}
|
||||
|
||||
|
||||
def _operator_notes(
|
||||
*,
|
||||
experiment_id: str,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
calibration_identity: str,
|
||||
temporal_offset_seconds: float,
|
||||
) -> bytes:
|
||||
return (
|
||||
f"# {experiment_id}\n\n"
|
||||
"Offline read-only diagnostic. Native MQTT/camera evidence was not modified.\n\n"
|
||||
f"- session: {session_id}\n"
|
||||
f"- source: {source_id}\n"
|
||||
f"- calibration content identity: {calibration_identity}\n"
|
||||
f"- requested host-arrival temporal offset: {temporal_offset_seconds:+.6f} s\n"
|
||||
"- point frame: K1 map\n"
|
||||
"- pose interpretation: T_map_from_lidar; inverted before factory extrinsic\n"
|
||||
"- camera model: KB4 at admitted 800x600\n\n"
|
||||
"This run proves a reproducible diagnostic layer only. Static landmark pixel "
|
||||
"correspondences and a temporal-offset error budget are still required before "
|
||||
"P0 acceptance.\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _verified_raw_sha256(raw_path: Path, summary: dict[str, object]) -> str:
|
||||
expected = _required_sha256(
|
||||
_required_mapping(summary.get("artifact_hashes"), "artifact hashes").get(
|
||||
"raw_sha256"
|
||||
),
|
||||
"raw MQTT sha256",
|
||||
)
|
||||
actual = _sha256_file(raw_path)
|
||||
if actual != expected:
|
||||
raise CalibratedOverlayExperimentError("raw MQTT identity changed")
|
||||
return actual
|
||||
|
||||
|
||||
def _artifact_document(path: Path) -> dict[str, object]:
|
||||
metadata = path.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size <= 0:
|
||||
raise CalibratedOverlayExperimentError("derived artifact is unavailable")
|
||||
return {
|
||||
"name": path.name,
|
||||
"bytes": metadata.st_size,
|
||||
"sha256": _sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path, max_bytes: int) -> dict[str, object]:
|
||||
payload = _read_regular(path, max_bytes)
|
||||
try:
|
||||
value = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is not valid JSON") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is not a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _read_regular(path: Path, max_bytes: int) -> bytes:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= max_bytes:
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is outside bounds")
|
||||
chunks: list[bytes] = []
|
||||
remaining = before.st_size
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(_READ_CHUNK_BYTES, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
if (
|
||||
remaining
|
||||
or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||
!= (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
):
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} changed during read")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _write_image_exclusive(path: Path, image_rgb: RgbImage) -> None:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as stream:
|
||||
Image.fromarray(image_rgb, mode="RGB").save(stream, format="PNG")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _write_exclusive(path: Path, payload: bytes) -> None:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
view = view[written:]
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(_READ_CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _required_mapping(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_list(value: object, label: str) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_sha256(value: object, label: str) -> str:
|
||||
text = _required_text(value, label)
|
||||
if len(text) != 64 or any(character not in "0123456789abcdef" for character in text):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _chmod_private(path: Path) -> None:
|
||||
current = path
|
||||
while current.name in {"private", "perception-experiments"}:
|
||||
with suppress(OSError):
|
||||
current.chmod(0o700)
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
ADMITTED_MAIN_STREAM_RESOLUTION,
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE,
|
||||
K1FactoryCalibration,
|
||||
)
|
||||
|
||||
FloatArray = npt.NDArray[np.float64]
|
||||
IntArray = npt.NDArray[np.int64]
|
||||
|
||||
|
||||
class CalibratedProjectionError(ValueError):
|
||||
"""Raised when a calibrated projection input violates the reviewed contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Kb4ProjectionProfile:
|
||||
source_id: str
|
||||
calibration_slot: str
|
||||
width: int
|
||||
height: int
|
||||
intrinsic_fx_fy_cx_cy: tuple[float, float, float, float]
|
||||
distortion_kb4: tuple[float, float, float, float]
|
||||
t_camera_from_lidar: FloatArray
|
||||
|
||||
@classmethod
|
||||
def from_factory_calibration(
|
||||
cls,
|
||||
calibration: K1FactoryCalibration,
|
||||
source_id: str,
|
||||
) -> Kb4ProjectionProfile:
|
||||
try:
|
||||
slot = MAIN_CAMERA_SLOT_BY_SOURCE[source_id]
|
||||
except KeyError as exc:
|
||||
raise CalibratedProjectionError("source is not an admitted K1 main camera") from exc
|
||||
camera = calibration.camera(slot)
|
||||
width, height = ADMITTED_MAIN_STREAM_RESOLUTION
|
||||
scale_x = width / camera.image_width
|
||||
scale_y = height / camera.image_height
|
||||
intrinsic = (
|
||||
camera.intrinsic[0] * scale_x,
|
||||
camera.intrinsic[1] * scale_y,
|
||||
camera.intrinsic[2] * scale_x,
|
||||
camera.intrinsic[3] * scale_y,
|
||||
)
|
||||
transform = np.asarray(calibration.t_camera_from_lidar(slot), dtype=np.float64)
|
||||
transform.setflags(write=False)
|
||||
return cls(
|
||||
source_id=source_id,
|
||||
calibration_slot=slot,
|
||||
width=width,
|
||||
height=height,
|
||||
intrinsic_fx_fy_cx_cy=intrinsic,
|
||||
distortion_kb4=camera.distortion,
|
||||
t_camera_from_lidar=transform,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectedPointCloud:
|
||||
pixels_xy: FloatArray
|
||||
depths_m: FloatArray
|
||||
source_indices: IntArray
|
||||
source_point_count: int
|
||||
camera_front_point_count: int
|
||||
|
||||
@property
|
||||
def projected_point_count(self) -> int:
|
||||
return int(self.pixels_xy.shape[0])
|
||||
|
||||
|
||||
def quaternion_xyzw_to_rotation_matrix(
|
||||
orientation_xyzw: tuple[float, float, float, float],
|
||||
) -> FloatArray:
|
||||
"""Return R_map_from_lidar for a finite, non-zero xyzw quaternion."""
|
||||
|
||||
quaternion = np.asarray(orientation_xyzw, dtype=np.float64)
|
||||
if quaternion.shape != (4,) or not np.isfinite(quaternion).all():
|
||||
raise CalibratedProjectionError("pose quaternion must contain four finite values")
|
||||
norm = float(np.linalg.norm(quaternion))
|
||||
if not math.isfinite(norm) or norm < 1e-9:
|
||||
raise CalibratedProjectionError("pose quaternion has no usable norm")
|
||||
x, y, z, w = quaternion / norm
|
||||
rotation = np.asarray(
|
||||
[
|
||||
[
|
||||
1.0 - 2.0 * (y * y + z * z),
|
||||
2.0 * (x * y - z * w),
|
||||
2.0 * (x * z + y * w),
|
||||
],
|
||||
[
|
||||
2.0 * (x * y + z * w),
|
||||
1.0 - 2.0 * (x * x + z * z),
|
||||
2.0 * (y * z - x * w),
|
||||
],
|
||||
[
|
||||
2.0 * (x * z - y * w),
|
||||
2.0 * (y * z + x * w),
|
||||
1.0 - 2.0 * (x * x + y * y),
|
||||
],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
rotation.setflags(write=False)
|
||||
return rotation
|
||||
|
||||
|
||||
def map_points_to_lidar(
|
||||
points_map_xyz: npt.ArrayLike,
|
||||
*,
|
||||
position_map_xyz: tuple[float, float, float],
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float],
|
||||
) -> FloatArray:
|
||||
"""Invert the K1 T_map_from_lidar pose for row-vector map points."""
|
||||
|
||||
points = _finite_points(points_map_xyz)
|
||||
position = np.asarray(position_map_xyz, dtype=np.float64)
|
||||
if position.shape != (3,) or not np.isfinite(position).all():
|
||||
raise CalibratedProjectionError("pose position must contain three finite values")
|
||||
rotation_map_from_lidar = quaternion_xyzw_to_rotation_matrix(
|
||||
orientation_map_from_lidar_xyzw
|
||||
)
|
||||
# Column-vector form is R.T @ (p_map - t). With row vectors this is
|
||||
# (p_map - t) @ R.
|
||||
return (points - position) @ rotation_map_from_lidar
|
||||
|
||||
|
||||
def project_map_points_kb4(
|
||||
points_map_xyz: npt.ArrayLike,
|
||||
*,
|
||||
position_map_xyz: tuple[float, float, float],
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float],
|
||||
profile: Kb4ProjectionProfile,
|
||||
) -> ProjectedPointCloud:
|
||||
"""Project one world-frame K1 lio_pcl frame into an admitted camera image."""
|
||||
|
||||
points_map = _finite_points(points_map_xyz)
|
||||
points_lidar = map_points_to_lidar(
|
||||
points_map,
|
||||
position_map_xyz=position_map_xyz,
|
||||
orientation_map_from_lidar_xyzw=orientation_map_from_lidar_xyzw,
|
||||
)
|
||||
transform = profile.t_camera_from_lidar
|
||||
if transform.shape != (4, 4) or not np.isfinite(transform).all():
|
||||
raise CalibratedProjectionError("camera transform must be a finite 4x4 matrix")
|
||||
points_camera = points_lidar @ transform[:3, :3].T + transform[:3, 3]
|
||||
x, y, z = points_camera.T
|
||||
front = z > 1e-6
|
||||
front_indices = np.flatnonzero(front)
|
||||
front_points = points_camera[front]
|
||||
if front_points.size == 0:
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=np.empty((0, 2), dtype=np.float64),
|
||||
depths_m=np.empty((0,), dtype=np.float64),
|
||||
source_indices=np.empty((0,), dtype=np.int64),
|
||||
source_point_count=int(points_map.shape[0]),
|
||||
camera_front_point_count=0,
|
||||
)
|
||||
|
||||
x, y, z = front_points.T
|
||||
radial = np.hypot(x, y)
|
||||
theta = np.arctan2(radial, z)
|
||||
theta_squared = theta * theta
|
||||
k1, k2, k3, k4 = profile.distortion_kb4
|
||||
theta_distorted = theta * (
|
||||
1.0
|
||||
+ k1 * theta_squared
|
||||
+ k2 * theta_squared**2
|
||||
+ k3 * theta_squared**3
|
||||
+ k4 * theta_squared**4
|
||||
)
|
||||
radial_scale = np.divide(
|
||||
theta_distorted,
|
||||
radial,
|
||||
out=np.zeros_like(theta_distorted),
|
||||
where=radial > 1e-12,
|
||||
)
|
||||
fx, fy, cx, cy = profile.intrinsic_fx_fy_cx_cy
|
||||
u = fx * x * radial_scale + cx
|
||||
v = fy * y * radial_scale + cy
|
||||
in_frame = (
|
||||
np.isfinite(u)
|
||||
& np.isfinite(v)
|
||||
& (u >= 0.0)
|
||||
& (u < profile.width)
|
||||
& (v >= 0.0)
|
||||
& (v < profile.height)
|
||||
)
|
||||
pixels = np.column_stack((u[in_frame], v[in_frame])).astype(np.float64, copy=False)
|
||||
depths = z[in_frame].astype(np.float64, copy=False)
|
||||
indices = front_indices[in_frame].astype(np.int64, copy=False)
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=pixels,
|
||||
depths_m=depths,
|
||||
source_indices=indices,
|
||||
source_point_count=int(points_map.shape[0]),
|
||||
camera_front_point_count=int(front_points.shape[0]),
|
||||
)
|
||||
|
||||
|
||||
def depth_colors(depths_m: npt.ArrayLike) -> npt.NDArray[np.uint8]:
|
||||
"""Return deterministic blue→cyan→green→yellow→red diagnostic colors."""
|
||||
|
||||
depths = np.asarray(depths_m, dtype=np.float64)
|
||||
if depths.ndim != 1 or not np.isfinite(depths).all() or np.any(depths <= 0.0):
|
||||
raise CalibratedProjectionError("projected depths must be a positive finite vector")
|
||||
if depths.size == 0:
|
||||
return np.empty((0, 3), dtype=np.uint8)
|
||||
lower, upper = np.percentile(depths, [5.0, 95.0])
|
||||
span = max(float(upper - lower), 1e-9)
|
||||
normalized = np.clip((depths - lower) / span, 0.0, 1.0)
|
||||
colors = np.column_stack(
|
||||
(
|
||||
255.0 * (1.0 - normalized),
|
||||
255.0 * (1.0 - np.abs(2.0 * normalized - 1.0)),
|
||||
255.0 * normalized,
|
||||
)
|
||||
)
|
||||
return np.rint(colors).astype(np.uint8)
|
||||
|
||||
|
||||
def _finite_points(points_xyz: npt.ArrayLike) -> FloatArray:
|
||||
points = np.asarray(points_xyz, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1:] != (3,):
|
||||
raise CalibratedProjectionError("point cloud must have shape (N, 3)")
|
||||
if not np.isfinite(points).all():
|
||||
raise CalibratedProjectionError("point cloud contains a non-finite coordinate")
|
||||
return points
|
||||
@@ -0,0 +1,472 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE,
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
)
|
||||
|
||||
VALID_FOV_MASK_SCHEMA = "missioncore.k1-valid-fov-mask/v1"
|
||||
VALID_FOV_MASK_IDENTITY_SCHEMA = "missioncore.k1-valid-fov-mask-identity/v1"
|
||||
DEFAULT_EDGE_MARGIN_PIXELS = 4.0
|
||||
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
||||
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_SAFE_GENERATION = re.compile(r"^valid-fov-mask-[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class K1ValidFovMaskError(RuntimeError):
|
||||
"""A K1 valid-FOV mask could not be derived or validated safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class K1ValidFovMask:
|
||||
generation_id: str
|
||||
root: Path
|
||||
manifest_path: Path
|
||||
mask_path: Path
|
||||
source_id: str
|
||||
calibration_slot: str
|
||||
calibration_sha256: str
|
||||
width: int
|
||||
height: int
|
||||
center_xy: tuple[float, float]
|
||||
radius_pixels: float
|
||||
crop_xyxy: tuple[int, int, int, int]
|
||||
valid_pixel_count: int
|
||||
valid_fraction: float
|
||||
|
||||
|
||||
def prepare_k1_valid_fov_mask(
|
||||
*,
|
||||
calibration_snapshot_root: Path,
|
||||
source_id: str,
|
||||
output_root: Path,
|
||||
edge_margin_pixels: float = DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
) -> K1ValidFovMask:
|
||||
"""Seal one reusable circular valid-FOV mask bound to exact K1 calibration.
|
||||
|
||||
The mask is generated once from the calibrated principal point and the
|
||||
admitted image transform. It is content-addressed by calibration, source,
|
||||
geometry and construction policy, so repeated experiments load the same
|
||||
PNG rather than estimating a lens boundary from every frame.
|
||||
"""
|
||||
|
||||
if source_id not in MAIN_CAMERA_SLOT_BY_SOURCE:
|
||||
raise K1ValidFovMaskError("source is not an admitted K1 main camera")
|
||||
if (
|
||||
isinstance(edge_margin_pixels, bool)
|
||||
or not isinstance(edge_margin_pixels, (int, float))
|
||||
or not math.isfinite(float(edge_margin_pixels))
|
||||
or not 0.0 <= float(edge_margin_pixels) <= 64.0
|
||||
):
|
||||
raise K1ValidFovMaskError("edge margin must be finite and between 0 and 64 pixels")
|
||||
|
||||
snapshot_root = calibration_snapshot_root.expanduser().resolve(strict=True)
|
||||
snapshot = _validated_snapshot(snapshot_root)
|
||||
calibration = parse_k1_factory_calibration(
|
||||
(snapshot_root / "camera.yaml").read_bytes(),
|
||||
(snapshot_root / "extrinsic_camera_lidar.yaml").read_bytes(),
|
||||
)
|
||||
normalized = calibration.normalized_profile()
|
||||
if snapshot.get("normalized_calibration") != normalized:
|
||||
raise K1ValidFovMaskError("calibration snapshot normalized profile changed")
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, source_id)
|
||||
stream_bindings = normalized.get("stream_bindings")
|
||||
if not isinstance(stream_bindings, dict):
|
||||
raise K1ValidFovMaskError("calibration stream bindings are unavailable")
|
||||
stream_binding = stream_bindings.get(source_id)
|
||||
if not isinstance(stream_binding, dict):
|
||||
raise K1ValidFovMaskError("calibration source binding is unavailable")
|
||||
|
||||
fx, fy, cx, cy = profile.intrinsic_fx_fy_cx_cy
|
||||
image_edge_radius = min(
|
||||
cx,
|
||||
float(profile.width - 1) - cx,
|
||||
cy,
|
||||
float(profile.height - 1) - cy,
|
||||
)
|
||||
radius = image_edge_radius - float(edge_margin_pixels)
|
||||
if not math.isfinite(radius) or radius < 32.0:
|
||||
raise K1ValidFovMaskError("valid-FOV construction leaves no usable image circle")
|
||||
|
||||
identity = {
|
||||
"schema_version": VALID_FOV_MASK_IDENTITY_SCHEMA,
|
||||
"calibration_sha256": snapshot["content_identity_sha256"],
|
||||
"source_id": source_id,
|
||||
"calibration_slot": profile.calibration_slot,
|
||||
"camera_model": "kb4",
|
||||
"admitted_resolution": [profile.width, profile.height],
|
||||
"admitted_intrinsic_fx_fy_cx_cy": [fx, fy, cx, cy],
|
||||
"distortion_kb4": list(profile.distortion_kb4),
|
||||
"image_transform": stream_binding.get("image_transform"),
|
||||
"construction": {
|
||||
"kind": "calibrated-principal-point-inscribed-circle",
|
||||
"pixel_coordinate_convention": "integer-pixel-centers",
|
||||
"edge_radius_policy": "minimum-distance-to-admitted-image-edge",
|
||||
"edge_margin_pixels": float(edge_margin_pixels),
|
||||
"outside_value": 0,
|
||||
"inside_value": 255,
|
||||
},
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"valid-fov-mask-{identity_sha256}"
|
||||
root = _prepare_private_directory(output_root)
|
||||
final = root / generation_id
|
||||
if final.exists():
|
||||
existing = validate_k1_valid_fov_mask(final)
|
||||
if existing.calibration_sha256 != snapshot["content_identity_sha256"]:
|
||||
raise K1ValidFovMaskError("valid-FOV generation collides with another calibration")
|
||||
return existing
|
||||
|
||||
staging = root / f".{generation_id}.{secrets.token_hex(12)}.incomplete"
|
||||
published = False
|
||||
try:
|
||||
staging.mkdir(mode=0o700)
|
||||
mask = _circle_mask(profile.width, profile.height, cx, cy, radius)
|
||||
valid_y, valid_x = np.nonzero(mask)
|
||||
if valid_x.size == 0 or valid_y.size == 0:
|
||||
raise K1ValidFovMaskError("valid-FOV mask is empty")
|
||||
crop = (
|
||||
int(valid_x.min()),
|
||||
int(valid_y.min()),
|
||||
int(valid_x.max()) + 1,
|
||||
int(valid_y.max()) + 1,
|
||||
)
|
||||
mask_path = staging / "mask.png"
|
||||
Image.fromarray(mask, mode="L").save(mask_path, format="PNG", optimize=False)
|
||||
_fsync_file(mask_path)
|
||||
os.chmod(mask_path, 0o600)
|
||||
mask_sha256 = _sha256_file(mask_path)
|
||||
valid_pixel_count = int(np.count_nonzero(mask))
|
||||
valid_fraction = valid_pixel_count / int(mask.size)
|
||||
manifest = {
|
||||
"schema_version": VALID_FOV_MASK_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"geometry": {
|
||||
"center_xy": [cx, cy],
|
||||
"image_edge_radius_pixels": image_edge_radius,
|
||||
"radius_pixels": radius,
|
||||
"crop_xyxy_exclusive": list(crop),
|
||||
"valid_pixel_count": valid_pixel_count,
|
||||
"total_pixel_count": int(mask.size),
|
||||
"valid_fraction": valid_fraction,
|
||||
},
|
||||
"artifact": {
|
||||
"path": "mask.png",
|
||||
"media_type": "image/png",
|
||||
"mode": "L",
|
||||
"inside_value": 255,
|
||||
"outside_value": 0,
|
||||
"byte_length": mask_path.stat().st_size,
|
||||
"sha256": mask_sha256,
|
||||
},
|
||||
"usage": {
|
||||
"quality": "set invalid lens exterior to the model profile's fixed fill value",
|
||||
"speed": (
|
||||
"apply crop_xyxy_exclusive before model preprocessing and map outputs back"
|
||||
),
|
||||
"dense_compute_warning": (
|
||||
"multiplying an unchanged 800x600 tensor by this mask alone does not reduce "
|
||||
"dense neural-network FLOPs"
|
||||
),
|
||||
},
|
||||
}
|
||||
write_json_atomic(staging / "manifest.json", manifest)
|
||||
os.chmod(staging / "manifest.json", 0o600)
|
||||
_fsync_directory(staging)
|
||||
os.replace(staging, final)
|
||||
_fsync_directory(root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
return validate_k1_valid_fov_mask(final)
|
||||
|
||||
|
||||
def validate_k1_valid_fov_mask(mask_root: Path) -> K1ValidFovMask:
|
||||
root = mask_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_GENERATION.fullmatch(root.name) is None:
|
||||
raise K1ValidFovMaskError("valid-FOV root is invalid")
|
||||
manifest = _read_json_object(root / "manifest.json", root, MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != VALID_FOV_MASK_SCHEMA
|
||||
or manifest.get("generation_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != VALID_FOV_MASK_IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"valid-fov-mask-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV identity is inconsistent")
|
||||
|
||||
artifact = manifest.get("artifact")
|
||||
geometry = manifest.get("geometry")
|
||||
resolution = identity.get("admitted_resolution")
|
||||
intrinsic = identity.get("admitted_intrinsic_fx_fy_cx_cy")
|
||||
if (
|
||||
not isinstance(artifact, dict)
|
||||
or artifact.get("path") != "mask.png"
|
||||
or artifact.get("media_type") != "image/png"
|
||||
or artifact.get("mode") != "L"
|
||||
or artifact.get("inside_value") != 255
|
||||
or artifact.get("outside_value") != 0
|
||||
or not isinstance(geometry, dict)
|
||||
or not isinstance(resolution, list)
|
||||
or len(resolution) != 2
|
||||
or not all(isinstance(value, int) and not isinstance(value, bool) for value in resolution)
|
||||
or not isinstance(intrinsic, list)
|
||||
or len(intrinsic) != 4
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV manifest contract is invalid")
|
||||
width, height = int(resolution[0]), int(resolution[1])
|
||||
if width < 1 or height < 1:
|
||||
raise K1ValidFovMaskError("valid-FOV resolution is invalid")
|
||||
mask_path = root / "mask.png"
|
||||
metadata = _confined_regular_file(mask_path, root)
|
||||
if (
|
||||
artifact.get("byte_length") != metadata.st_size
|
||||
or not isinstance(artifact.get("sha256"), str)
|
||||
or _SHA256.fullmatch(str(artifact["sha256"])) is None
|
||||
or _sha256_file(mask_path) != artifact["sha256"]
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV mask artifact changed")
|
||||
try:
|
||||
with Image.open(mask_path) as opened:
|
||||
if opened.mode != "L" or opened.size != (width, height):
|
||||
raise K1ValidFovMaskError("valid-FOV PNG dimensions or mode changed")
|
||||
mask = np.asarray(opened, dtype=np.uint8)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is unavailable") from exc
|
||||
if not np.isin(mask, (0, 255)).all():
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is not binary")
|
||||
valid_y, valid_x = np.nonzero(mask)
|
||||
if valid_x.size == 0 or valid_y.size == 0:
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is empty")
|
||||
crop = (
|
||||
int(valid_x.min()),
|
||||
int(valid_y.min()),
|
||||
int(valid_x.max()) + 1,
|
||||
int(valid_y.max()) + 1,
|
||||
)
|
||||
count = int(valid_x.size)
|
||||
center = geometry.get("center_xy")
|
||||
radius = geometry.get("radius_pixels")
|
||||
expected_crop = geometry.get("crop_xyxy_exclusive")
|
||||
expected_count = geometry.get("valid_pixel_count")
|
||||
expected_total = geometry.get("total_pixel_count")
|
||||
expected_fraction = geometry.get("valid_fraction")
|
||||
if (
|
||||
not isinstance(center, list)
|
||||
or len(center) != 2
|
||||
or not all(isinstance(value, (int, float)) for value in center)
|
||||
or not isinstance(radius, (int, float))
|
||||
or not math.isfinite(float(radius))
|
||||
or list(crop) != expected_crop
|
||||
or count != expected_count
|
||||
or mask.size != expected_total
|
||||
or not isinstance(expected_fraction, (int, float))
|
||||
or not math.isclose(count / mask.size, float(expected_fraction), abs_tol=1e-12)
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV geometry changed")
|
||||
source_id = identity.get("source_id")
|
||||
calibration_slot = identity.get("calibration_slot")
|
||||
calibration_sha256 = identity.get("calibration_sha256")
|
||||
if (
|
||||
not isinstance(source_id, str)
|
||||
or source_id not in MAIN_CAMERA_SLOT_BY_SOURCE
|
||||
or not isinstance(calibration_slot, str)
|
||||
or calibration_slot != MAIN_CAMERA_SLOT_BY_SOURCE[source_id]
|
||||
or not isinstance(calibration_sha256, str)
|
||||
or _SHA256.fullmatch(calibration_sha256) is None
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV calibration binding is invalid")
|
||||
return K1ValidFovMask(
|
||||
generation_id=root.name,
|
||||
root=root,
|
||||
manifest_path=root / "manifest.json",
|
||||
mask_path=mask_path,
|
||||
source_id=source_id,
|
||||
calibration_slot=calibration_slot,
|
||||
calibration_sha256=calibration_sha256,
|
||||
width=width,
|
||||
height=height,
|
||||
center_xy=(float(center[0]), float(center[1])),
|
||||
radius_pixels=float(radius),
|
||||
crop_xyxy=crop,
|
||||
valid_pixel_count=count,
|
||||
valid_fraction=count / mask.size,
|
||||
)
|
||||
|
||||
|
||||
def _validated_snapshot(root: Path) -> dict[str, Any]:
|
||||
manifest = _read_json_object(root / "manifest.json", root, MAX_MANIFEST_BYTES)
|
||||
artifacts = manifest.get("artifacts")
|
||||
device = manifest.get("device")
|
||||
identity_sha256 = manifest.get("content_identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != CALIBRATION_SNAPSHOT_MANIFEST_VERSION
|
||||
or not isinstance(artifacts, list)
|
||||
or len(artifacts) != 2
|
||||
or not isinstance(device, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
):
|
||||
raise K1ValidFovMaskError("calibration snapshot manifest is incompatible")
|
||||
expected_names = {"camera.yaml", "extrinsic_camera_lidar.yaml"}
|
||||
by_name: dict[str, dict[str, Any]] = {}
|
||||
by_source: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact is invalid")
|
||||
name = artifact.get("artifact_name")
|
||||
source_path = artifact.get("source_path")
|
||||
digest = artifact.get("sha256")
|
||||
byte_length = artifact.get("bytes")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or name not in expected_names
|
||||
or name in by_name
|
||||
or not isinstance(source_path, str)
|
||||
or not source_path.startswith("/mnt/system/factory-data/config/")
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < 1
|
||||
):
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact descriptor is invalid")
|
||||
path = root / name
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if metadata.st_size != byte_length or _sha256_file(path) != digest:
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact changed")
|
||||
by_name[name] = artifact
|
||||
by_source[source_path] = digest
|
||||
if set(by_name) != expected_names or len(by_source) != 2:
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact set changed")
|
||||
vendor_device_id = device.get("vendor_device_id")
|
||||
device_serial = device.get("device_serial")
|
||||
if not isinstance(vendor_device_id, str) or not isinstance(device_serial, str):
|
||||
raise K1ValidFovMaskError("calibration snapshot device binding is unavailable")
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
digest.update(vendor_device_id.encode("ascii"))
|
||||
digest.update(b"\x00")
|
||||
digest.update(device_serial.encode("ascii"))
|
||||
except UnicodeEncodeError as exc:
|
||||
raise K1ValidFovMaskError("calibration snapshot device binding is invalid") from exc
|
||||
for source_path in sorted(by_source):
|
||||
digest.update(b"\x00")
|
||||
digest.update(source_path.encode("utf-8"))
|
||||
digest.update(bytes.fromhex(by_source[source_path]))
|
||||
if digest.hexdigest() != identity_sha256:
|
||||
raise K1ValidFovMaskError("calibration snapshot content identity changed")
|
||||
return manifest
|
||||
|
||||
|
||||
def _circle_mask(width: int, height: int, cx: float, cy: float, radius: float) -> np.ndarray:
|
||||
y, x = np.ogrid[:height, :width]
|
||||
inside = (x - cx) ** 2 + (y - cy) ** 2 <= radius**2
|
||||
return np.where(inside, 255, 0).astype(np.uint8)
|
||||
|
||||
|
||||
def _prepare_private_directory(path: Path) -> Path:
|
||||
candidate = path.expanduser()
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
metadata = candidate.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise K1ValidFovMaskError("valid-FOV output root must be a real directory")
|
||||
root = candidate.resolve(strict=True)
|
||||
os.chmod(root, 0o700)
|
||||
return root
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= maximum_bytes:
|
||||
raise K1ValidFovMaskError("JSON manifest is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise K1ValidFovMaskError("JSON manifest is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise K1ValidFovMaskError("JSON manifest is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise K1ValidFovMaskError("artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise K1ValidFovMaskError("artifact is not a confined regular file")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -42,10 +42,7 @@ SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
|
||||
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
|
||||
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
|
||||
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
|
||||
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
|
||||
MAX_RECOVERY_MESSAGES = 500_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -560,20 +557,12 @@ def _scan_capture_prefix(
|
||||
first_timestamp: str | None = None
|
||||
last_timestamp: str | None = None
|
||||
metadata_committed_bytes = 0
|
||||
while (
|
||||
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
|
||||
and message_count < MAX_RECOVERY_MESSAGES
|
||||
):
|
||||
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
|
||||
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
|
||||
line = metadata_stream.readline(read_limit)
|
||||
while True:
|
||||
line = metadata_stream.readline(MAX_RECOVERY_METADATA_LINE_BYTES + 1)
|
||||
if not line:
|
||||
break
|
||||
consumed_metadata_bytes += len(line)
|
||||
if (
|
||||
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
|
||||
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
|
||||
):
|
||||
if len(line) > MAX_RECOVERY_METADATA_LINE_BYTES:
|
||||
return None
|
||||
if not line.endswith(b"\n"):
|
||||
if tolerate_incomplete_metadata_tail:
|
||||
@@ -1015,7 +1004,7 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||
or init_path.stat().st_size <= 0
|
||||
or not segments_root.is_dir()
|
||||
or not index_path.is_file()
|
||||
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
|
||||
or index_path.stat().st_size <= 0
|
||||
or not summary_path.is_file()
|
||||
):
|
||||
return False
|
||||
@@ -1027,30 +1016,37 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||
segment_count = _non_negative_int(summary.get("segment_count"))
|
||||
if segment_count < 1:
|
||||
return False
|
||||
segments = [
|
||||
path.resolve()
|
||||
for path in sorted(segments_root.iterdir())
|
||||
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
|
||||
]
|
||||
if len(segments) != segment_count or any(
|
||||
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
|
||||
segment_sequences: set[int] = set()
|
||||
for path in segments_root.iterdir():
|
||||
match = MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
|
||||
if match is None or not path.is_file():
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
sequence = int(path.stem)
|
||||
if (
|
||||
resolved.parent != segments_root
|
||||
or path.name != f"{sequence}.m4s"
|
||||
or resolved.stat().st_size <= 0
|
||||
or sequence in segment_sequences
|
||||
):
|
||||
return False
|
||||
segment_sequences.add(sequence)
|
||||
if (
|
||||
len(segment_sequences) != segment_count
|
||||
or min(segment_sequences, default=0) != 1
|
||||
or max(segment_sequences, default=0) != segment_count
|
||||
):
|
||||
return False
|
||||
try:
|
||||
index_records = [
|
||||
json.loads(line)
|
||||
for line in index_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
index_count = 0
|
||||
for index_count, line in enumerate(stream, start=1):
|
||||
record = json.loads(line)
|
||||
if not isinstance(record, dict) or record.get("sequence") != index_count:
|
||||
return False
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
if len(index_records) != segment_count or not all(
|
||||
isinstance(record, dict) and _non_negative_int(record.get("sequence")) > 0
|
||||
for record in index_records
|
||||
):
|
||||
return False
|
||||
sequences = [int(record["sequence"]) for record in index_records]
|
||||
return len(sequences) == len(set(sequences))
|
||||
return index_count == segment_count
|
||||
|
||||
|
||||
def _media_epoch_bytes(epoch: Path) -> int:
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib.metadata import version
|
||||
from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
SERVICE_UUID,
|
||||
STATUS_CHARACTERISTIC_UUID,
|
||||
WRITE_CHARACTERISTIC_UUID,
|
||||
ResolvedWriteMode,
|
||||
StatusObservation,
|
||||
WifiStatus,
|
||||
WriteMode,
|
||||
parse_wifi_status,
|
||||
)
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-quick-connect-ap-v1"
|
||||
FRAME_LENGTH = 100
|
||||
COMMAND_OFFSET = 99
|
||||
ENABLE_AP_COMMAND = 1
|
||||
|
||||
ApActivationOutcome = Literal[
|
||||
"already_active",
|
||||
"ap_ready_observed",
|
||||
"status_changed",
|
||||
"ble_disconnected_after_write",
|
||||
"no_status_change_before_timeout",
|
||||
]
|
||||
|
||||
|
||||
class ApActivationResult(TypedDict):
|
||||
schema_version: int
|
||||
profile_id: str
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
service_uuid: str
|
||||
write_characteristic_uuid: str
|
||||
status_characteristic_uuid: str
|
||||
operation: str
|
||||
write_performed: bool
|
||||
write_mode: ResolvedWriteMode | None
|
||||
write_without_response_advertised: bool
|
||||
max_write_without_response_size: int
|
||||
frame_length: int
|
||||
baseline_status: WifiStatus
|
||||
observations: list[StatusObservation]
|
||||
ready_observed: bool
|
||||
outcome: ApActivationOutcome
|
||||
|
||||
|
||||
def build_ap_activation_frame() -> bytearray:
|
||||
"""Build LixelGO's fixed 100-byte Quick Connect AP-enable frame."""
|
||||
|
||||
frame = bytearray(FRAME_LENGTH)
|
||||
frame[COMMAND_OFFSET] = ENABLE_AP_COMMAND
|
||||
return frame
|
||||
|
||||
|
||||
def is_ap_ready_status(status: WifiStatus) -> bool:
|
||||
# The reviewed LixelGO build maps byte 51 of the 7f02 response to its
|
||||
# Wi-Fi-AP-ready flag. WIFI_AP plus the fallback address describes the
|
||||
# selected control mode, but can remain stale after the beacon disappears.
|
||||
return (
|
||||
status["mode"] == "WIFI_AP"
|
||||
and status["ipv4"] == AP_FALLBACK_IPV4
|
||||
and status["reserved"] not in (None, 0)
|
||||
)
|
||||
|
||||
|
||||
def _outcome(
|
||||
baseline: WifiStatus,
|
||||
observations: list[StatusObservation],
|
||||
disconnected: bool,
|
||||
) -> ApActivationOutcome:
|
||||
if observations:
|
||||
final = observations[-1]["status"]
|
||||
if is_ap_ready_status(final):
|
||||
return "ap_ready_observed"
|
||||
if final != baseline:
|
||||
return "status_changed"
|
||||
if disconnected:
|
||||
return "ble_disconnected_after_write"
|
||||
return "no_status_change_before_timeout"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def device_ap_activation_session(
|
||||
device_macos_uuid: str,
|
||||
timeout_seconds: float = 15.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
write_mode: WriteMode = "auto",
|
||||
) -> AsyncIterator[ApActivationResult]:
|
||||
"""Keep BLE connected around one reviewed Quick Connect AP-enable write.
|
||||
|
||||
The payload is the exact fixed frame used by the reviewed LixelGO build.
|
||||
``auto`` follows the live GATT properties because the owner-controlled K1
|
||||
advertises a write with response even though the Android client requests a
|
||||
write without response. The caller receives the result while the same BLE
|
||||
session is still alive, matching LixelGO's AP-ready -> native Wi-Fi handoff.
|
||||
No transport or command retry is attempted.
|
||||
"""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
if poll_interval_seconds <= 0:
|
||||
raise ValueError("poll_interval_seconds must be positive")
|
||||
if write_mode not in ("auto", "with_response", "without_response"):
|
||||
raise ValueError(f"Unsupported write mode: {write_mode}")
|
||||
|
||||
frame = build_ap_activation_frame()
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
device_name = client.name
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 AP-control characteristic is attached to an unexpected service"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = write_characteristic.max_write_without_response_size
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
break
|
||||
raise
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if is_ap_ready_status(status):
|
||||
break
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
result: ApActivationResult = {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
# Keep the same CoreBluetooth session alive while the caller waits
|
||||
# for and performs the host-side CoreWLAN association. LixelGO does
|
||||
# not tear down this BLE manager between its AP-ready callback and
|
||||
# native Wi-Fi connect call.
|
||||
yield result
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
|
||||
async def activate_device_ap_once(
|
||||
device_macos_uuid: str,
|
||||
timeout_seconds: float = 15.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
write_mode: WriteMode = "auto",
|
||||
) -> ApActivationResult:
|
||||
"""Run one AP activation and release BLE immediately after its result.
|
||||
|
||||
Host association flows must use :func:`device_ap_activation_session` so
|
||||
the reviewed LixelGO BLE-to-Wi-Fi handoff remains one connected session.
|
||||
"""
|
||||
|
||||
async with device_ap_activation_session(
|
||||
device_macos_uuid,
|
||||
timeout_seconds=timeout_seconds,
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
write_mode=write_mode,
|
||||
) as result:
|
||||
return result
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version
|
||||
from threading import Lock
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -9,6 +10,9 @@ from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
_runtime_handle_lock = Lock()
|
||||
_runtime_handles: dict[str, BLEDevice] = {}
|
||||
|
||||
|
||||
class BleDeviceRecord(TypedDict):
|
||||
macos_uuid: str
|
||||
@@ -58,12 +62,24 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
|
||||
}
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return the live CoreBluetooth handle retained by the latest explicit scan."""
|
||||
|
||||
with _runtime_handle_lock:
|
||||
return _runtime_handles.get(macos_uuid)
|
||||
|
||||
|
||||
async def scan(duration_seconds: float) -> BleScanResult:
|
||||
if duration_seconds <= 0:
|
||||
raise ValueError("duration_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
|
||||
with _runtime_handle_lock:
|
||||
_runtime_handles.clear()
|
||||
_runtime_handles.update(
|
||||
{device.address: device for device, _advertisement in discovered.values()}
|
||||
)
|
||||
devices = [
|
||||
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
|
||||
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
@@ -66,6 +67,21 @@ class WifiProvisioningResult(TypedDict):
|
||||
outcome: ProvisioningOutcome
|
||||
|
||||
|
||||
class WifiStatusReadResult(TypedDict):
|
||||
schema_version: int
|
||||
profile_id: str
|
||||
observed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
service_uuid: str
|
||||
status_characteristic_uuid: str
|
||||
operation: Literal["single_reviewed_wifi_status_read"]
|
||||
write_performed: Literal[False]
|
||||
status: WifiStatus
|
||||
|
||||
|
||||
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
|
||||
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
|
||||
ssid_bytes = ssid.encode("utf-8")
|
||||
@@ -143,6 +159,62 @@ def _outcome(
|
||||
return "no_status_change_before_timeout"
|
||||
|
||||
|
||||
async def read_wifi_status_once(
|
||||
device_macos_uuid: str,
|
||||
*,
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> WifiStatusReadResult:
|
||||
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
async with asyncio.timeout(timeout_seconds + 5.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": client.name or "",
|
||||
"service_uuid": service.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_wifi_status_read",
|
||||
"write_performed": False,
|
||||
"status": parse_wifi_status(value),
|
||||
}
|
||||
|
||||
|
||||
async def provision_wifi_once(
|
||||
device_macos_uuid: str,
|
||||
ssid: str,
|
||||
@@ -166,10 +238,12 @@ async def provision_wifi_once(
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast
|
||||
|
||||
import yaml
|
||||
from yaml.constructor import ConstructorError
|
||||
from yaml.tokens import AliasToken, AnchorToken, TagToken
|
||||
|
||||
MAX_FACTORY_YAML_BYTES: Final = 64 * 1024
|
||||
CAMERA_KEYS: Final = ("camera_0", "camera_1", "camera_2", "camera_3")
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE: Final = {
|
||||
"sensor.camera.left": "camera_0",
|
||||
"sensor.camera.right": "camera_1",
|
||||
}
|
||||
MAIN_CAMERA_RTSP_PATH_BY_SOURCE: Final = {
|
||||
"sensor.camera.left": "/live/chn_left_main",
|
||||
"sensor.camera.right": "/live/chn_right_main",
|
||||
}
|
||||
EXPECTED_CAMERA_RESOLUTION: Final = {
|
||||
"camera_0": (4000, 3000),
|
||||
"camera_1": (4000, 3000),
|
||||
"camera_2": (1280, 800),
|
||||
"camera_3": (1280, 800),
|
||||
}
|
||||
ADMITTED_MAIN_STREAM_RESOLUTION: Final = (800, 600)
|
||||
|
||||
Matrix4 = tuple[
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
]
|
||||
|
||||
|
||||
class FactoryCalibrationSchemaError(ValueError):
|
||||
"""A factory calibration document is unsafe or outside the reviewed K1 schema."""
|
||||
|
||||
|
||||
class _UniqueSafeLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def _construct_unique_mapping(
|
||||
loader: _UniqueSafeLoader,
|
||||
node: yaml.MappingNode,
|
||||
deep: bool = False,
|
||||
) -> dict[object, object]:
|
||||
mapping: dict[object, object] = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
try:
|
||||
duplicate = key in mapping
|
||||
except TypeError as exc:
|
||||
raise ConstructorError(
|
||||
"while constructing a mapping",
|
||||
node.start_mark,
|
||||
"found an unhashable key",
|
||||
key_node.start_mark,
|
||||
) from exc
|
||||
if duplicate:
|
||||
raise ConstructorError(
|
||||
"while constructing a mapping",
|
||||
node.start_mark,
|
||||
f"found duplicate key {key!r}",
|
||||
key_node.start_mark,
|
||||
)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
|
||||
_UniqueSafeLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
_construct_unique_mapping,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraCalibration:
|
||||
slot: str
|
||||
camera_model: str
|
||||
camera_pose: Matrix4
|
||||
distortion: tuple[float, float, float, float]
|
||||
image_width: int
|
||||
image_height: int
|
||||
intrinsic: tuple[float, float, float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class K1FactoryCalibration:
|
||||
version: str
|
||||
cameras: tuple[
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
]
|
||||
t_camera_0_from_lidar: Matrix4
|
||||
|
||||
def camera(self, slot: str) -> CameraCalibration:
|
||||
for camera in self.cameras:
|
||||
if camera.slot == slot:
|
||||
return camera
|
||||
raise KeyError(slot)
|
||||
|
||||
def t_camera_from_lidar(self, slot: str) -> Matrix4:
|
||||
camera = self.camera(slot)
|
||||
return _matrix_multiply(
|
||||
_rigid_inverse(camera.camera_pose),
|
||||
self.t_camera_0_from_lidar,
|
||||
)
|
||||
|
||||
def normalized_profile(self) -> dict[str, object]:
|
||||
cameras: list[dict[str, object]] = []
|
||||
for camera in self.cameras:
|
||||
cameras.append(
|
||||
{
|
||||
"slot": camera.slot,
|
||||
"model": camera.camera_model,
|
||||
"native_resolution": [camera.image_width, camera.image_height],
|
||||
"intrinsic_fx_fy_cx_cy": list(camera.intrinsic),
|
||||
"distortion_kb4": list(camera.distortion),
|
||||
"serialized_camera_pose": {
|
||||
"direction": f"T_camera_0_from_{camera.slot}",
|
||||
"row_major": _matrix_as_lists(camera.camera_pose),
|
||||
},
|
||||
"t_camera_from_lidar": {
|
||||
"direction": f"T_{camera.slot}_from_lidar",
|
||||
"row_major": _matrix_as_lists(
|
||||
self.t_camera_from_lidar(camera.slot)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
streams: dict[str, object] = {}
|
||||
target_width, target_height = ADMITTED_MAIN_STREAM_RESOLUTION
|
||||
for source_id, slot in MAIN_CAMERA_SLOT_BY_SOURCE.items():
|
||||
camera = self.camera(slot)
|
||||
scale_x = target_width / camera.image_width
|
||||
scale_y = target_height / camera.image_height
|
||||
streams[source_id] = {
|
||||
"calibration_slot": slot,
|
||||
"rtsp_path": MAIN_CAMERA_RTSP_PATH_BY_SOURCE[source_id],
|
||||
"native_resolution": [camera.image_width, camera.image_height],
|
||||
"admitted_resolution": [target_width, target_height],
|
||||
"image_transform": {
|
||||
"kind": "firmware-configured-linear-resize",
|
||||
"scale_x": scale_x,
|
||||
"scale_y": scale_y,
|
||||
"crop": None,
|
||||
"warp": None,
|
||||
},
|
||||
"admitted_intrinsic_fx_fy_cx_cy": [
|
||||
camera.intrinsic[0] * scale_x,
|
||||
camera.intrinsic[1] * scale_y,
|
||||
camera.intrinsic[2] * scale_x,
|
||||
camera.intrinsic[3] * scale_y,
|
||||
],
|
||||
"distortion_kb4": list(camera.distortion),
|
||||
"t_camera_from_lidar": {
|
||||
"direction": f"T_{slot}_from_lidar",
|
||||
"row_major": _matrix_as_lists(self.t_camera_from_lidar(slot)),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": "missioncore.k1-normalized-calibration/v1",
|
||||
"vendor_schema_version": self.version,
|
||||
"transform_notation": "T_destination_from_source",
|
||||
"matrix_storage": "row-major-homogeneous-4x4",
|
||||
"translation_unit": "meter",
|
||||
"base_transform": {
|
||||
"direction": "T_camera_0_from_lidar",
|
||||
"row_major": _matrix_as_lists(self.t_camera_0_from_lidar),
|
||||
},
|
||||
"camera_pose_interpretation": (
|
||||
"serialized T_camera_0_from_camera_N; firmware xcolor inverts it "
|
||||
"before composing T_camera_N_from_lidar"
|
||||
),
|
||||
"cameras": cameras,
|
||||
"stream_bindings": streams,
|
||||
"mapping_proof": {
|
||||
"status": "firmware-profile-verified",
|
||||
"basis": [
|
||||
"K1 firmware main-camera declaration order",
|
||||
"xcolor camera_N sequential loader and main-topic order",
|
||||
"factory native resolutions matching K1 main/secondary profiles",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_k1_factory_calibration(
|
||||
camera_yaml: bytes,
|
||||
camera_lidar_yaml: bytes,
|
||||
) -> K1FactoryCalibration:
|
||||
camera_document = _load_reviewed_yaml(camera_yaml, "camera.yaml")
|
||||
extrinsic_document = _load_reviewed_yaml(
|
||||
camera_lidar_yaml,
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
)
|
||||
camera_root = _exact_mapping(
|
||||
camera_document,
|
||||
{"calibrated", "version", *CAMERA_KEYS},
|
||||
"camera.yaml",
|
||||
)
|
||||
extrinsic_root = _exact_mapping(
|
||||
extrinsic_document,
|
||||
{"calibrated", "version", "transform"},
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
)
|
||||
if camera_root["calibrated"] is not True:
|
||||
raise FactoryCalibrationSchemaError("camera.yaml is not marked calibrated")
|
||||
if extrinsic_root["calibrated"] is not True:
|
||||
raise FactoryCalibrationSchemaError(
|
||||
"extrinsic_camera_lidar.yaml is not marked calibrated"
|
||||
)
|
||||
camera_version = _short_text(camera_root["version"], "camera.yaml.version")
|
||||
extrinsic_version = _short_text(
|
||||
extrinsic_root["version"],
|
||||
"extrinsic_camera_lidar.yaml.version",
|
||||
)
|
||||
if camera_version != extrinsic_version:
|
||||
raise FactoryCalibrationSchemaError("factory calibration versions do not match")
|
||||
|
||||
parsed_cameras = tuple(
|
||||
_parse_camera(slot, camera_root[slot]) for slot in CAMERA_KEYS
|
||||
)
|
||||
camera_tuple = cast(
|
||||
tuple[
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
],
|
||||
parsed_cameras,
|
||||
)
|
||||
if not _matrix_close(camera_tuple[0].camera_pose, _identity_matrix(), 1e-6):
|
||||
raise FactoryCalibrationSchemaError("camera_0 pose must be the identity reference")
|
||||
|
||||
base_transform = _matrix4(
|
||||
extrinsic_root["transform"],
|
||||
"extrinsic_camera_lidar.yaml.transform",
|
||||
)
|
||||
_validate_rigid_transform(
|
||||
base_transform,
|
||||
"extrinsic_camera_lidar.yaml.transform",
|
||||
)
|
||||
return K1FactoryCalibration(
|
||||
version=camera_version,
|
||||
cameras=camera_tuple,
|
||||
t_camera_0_from_lidar=base_transform,
|
||||
)
|
||||
|
||||
|
||||
def _load_reviewed_yaml(payload: bytes, label: str) -> object:
|
||||
if len(payload) > MAX_FACTORY_YAML_BYTES:
|
||||
raise FactoryCalibrationSchemaError(f"{label} exceeds the parser byte bound")
|
||||
if b"\x00" in payload:
|
||||
raise FactoryCalibrationSchemaError(f"{label} contains NUL bytes")
|
||||
try:
|
||||
text = payload.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FactoryCalibrationSchemaError(f"{label} is not valid UTF-8") from exc
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=_UniqueSafeLoader):
|
||||
if isinstance(token, (AliasToken, AnchorToken, TagToken)):
|
||||
raise FactoryCalibrationSchemaError(
|
||||
f"{label} contains YAML anchors, aliases or explicit tags"
|
||||
)
|
||||
value = yaml.load(text, Loader=_UniqueSafeLoader)
|
||||
except FactoryCalibrationSchemaError:
|
||||
raise
|
||||
except yaml.YAMLError as exc:
|
||||
raise FactoryCalibrationSchemaError(f"{label} is not safe valid YAML") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _exact_mapping(value: object, keys: set[str], label: str) -> dict[str, object]:
|
||||
if type(value) is not dict:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a mapping")
|
||||
mapping = cast(dict[object, object], value)
|
||||
if not all(type(key) is str for key in mapping):
|
||||
raise FactoryCalibrationSchemaError(f"{label} keys must be strings")
|
||||
typed = cast(dict[str, object], mapping)
|
||||
if set(typed) != keys:
|
||||
raise FactoryCalibrationSchemaError(f"{label} has an unexpected key set")
|
||||
return typed
|
||||
|
||||
|
||||
def _parse_camera(slot: str, value: object) -> CameraCalibration:
|
||||
label = f"camera.yaml.{slot}"
|
||||
node = _exact_mapping(
|
||||
value,
|
||||
{
|
||||
"camera_model",
|
||||
"camera_pose",
|
||||
"distortion",
|
||||
"image_height",
|
||||
"image_width",
|
||||
"intrinsic",
|
||||
},
|
||||
label,
|
||||
)
|
||||
model = _short_text(node["camera_model"], f"{label}.camera_model")
|
||||
if model != "kb4":
|
||||
raise FactoryCalibrationSchemaError(f"{label} must use the reviewed kb4 model")
|
||||
width = _positive_integer(node["image_width"], f"{label}.image_width")
|
||||
height = _positive_integer(node["image_height"], f"{label}.image_height")
|
||||
if (width, height) != EXPECTED_CAMERA_RESOLUTION[slot]:
|
||||
raise FactoryCalibrationSchemaError(f"{label} resolution does not match K1 FW 3.0.2")
|
||||
intrinsic = _float4(node["intrinsic"], f"{label}.intrinsic")
|
||||
fx, fy, cx, cy = intrinsic
|
||||
if fx <= 0 or fy <= 0 or not (0 <= cx <= width) or not (0 <= cy <= height):
|
||||
raise FactoryCalibrationSchemaError(f"{label}.intrinsic is not physically admissible")
|
||||
pose = _matrix4(node["camera_pose"], f"{label}.camera_pose")
|
||||
_validate_rigid_transform(pose, f"{label}.camera_pose")
|
||||
return CameraCalibration(
|
||||
slot=slot,
|
||||
camera_model=model,
|
||||
camera_pose=pose,
|
||||
distortion=_float4(node["distortion"], f"{label}.distortion"),
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
intrinsic=intrinsic,
|
||||
)
|
||||
|
||||
|
||||
def _short_text(value: object, label: str) -> str:
|
||||
if type(value) is not str:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a string")
|
||||
text = value
|
||||
if not text or len(text) > 64 or any(ord(character) < 32 for character in text):
|
||||
raise FactoryCalibrationSchemaError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _positive_integer(value: object, label: str) -> int:
|
||||
if type(value) is not int or value <= 0:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _finite_number(value: object, label: str) -> float:
|
||||
if type(value) not in {int, float}:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be numeric")
|
||||
number = float(cast(int | float, value))
|
||||
if not math.isfinite(number):
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be finite")
|
||||
return number
|
||||
|
||||
|
||||
def _float4(value: object, label: str) -> tuple[float, float, float, float]:
|
||||
if type(value) is not list or len(cast(list[object], value)) != 4:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must contain exactly four numbers")
|
||||
numbers = tuple(
|
||||
_finite_number(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(cast(list[object], value))
|
||||
)
|
||||
return cast(tuple[float, float, float, float], numbers)
|
||||
|
||||
|
||||
def _matrix4(value: object, label: str) -> Matrix4:
|
||||
if type(value) is not list or len(cast(list[object], value)) != 16:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must contain exactly 16 numbers")
|
||||
numbers = [
|
||||
_finite_number(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(cast(list[object], value))
|
||||
]
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(tuple(numbers[row * 4 : row * 4 + 4]) for row in range(4)),
|
||||
)
|
||||
|
||||
|
||||
def _validate_rigid_transform(matrix: Matrix4, label: str) -> None:
|
||||
if not _matrix_close_row(matrix[3], (0.0, 0.0, 0.0, 1.0), 1e-6):
|
||||
raise FactoryCalibrationSchemaError(f"{label} has an invalid homogeneous row")
|
||||
rotation = tuple(tuple(matrix[row][column] for column in range(3)) for row in range(3))
|
||||
for left in range(3):
|
||||
for right in range(3):
|
||||
dot = sum(rotation[left][axis] * rotation[right][axis] for axis in range(3))
|
||||
expected = 1.0 if left == right else 0.0
|
||||
if abs(dot - expected) > 1e-3:
|
||||
raise FactoryCalibrationSchemaError(f"{label} rotation is not orthonormal")
|
||||
determinant = (
|
||||
rotation[0][0]
|
||||
* (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
|
||||
- rotation[0][1]
|
||||
* (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
|
||||
+ rotation[0][2]
|
||||
* (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0])
|
||||
)
|
||||
if abs(determinant - 1.0) > 1e-3:
|
||||
raise FactoryCalibrationSchemaError(f"{label} rotation determinant is not +1")
|
||||
if math.sqrt(sum(matrix[row][3] ** 2 for row in range(3))) > 10.0:
|
||||
raise FactoryCalibrationSchemaError(f"{label} translation is outside the meter bound")
|
||||
|
||||
|
||||
def _rigid_inverse(matrix: Matrix4) -> Matrix4:
|
||||
rotation_transpose = tuple(
|
||||
tuple(matrix[column][row] for column in range(3)) for row in range(3)
|
||||
)
|
||||
translation = tuple(matrix[row][3] for row in range(3))
|
||||
inverted_translation = tuple(
|
||||
-sum(rotation_transpose[row][axis] * translation[axis] for axis in range(3))
|
||||
for row in range(3)
|
||||
)
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(
|
||||
tuple((*rotation_transpose[row], inverted_translation[row]))
|
||||
for row in range(3)
|
||||
)
|
||||
+ ((0.0, 0.0, 0.0, 1.0),),
|
||||
)
|
||||
|
||||
|
||||
def _matrix_multiply(left: Matrix4, right: Matrix4) -> Matrix4:
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(
|
||||
tuple(
|
||||
sum(left[row][axis] * right[axis][column] for axis in range(4))
|
||||
for column in range(4)
|
||||
)
|
||||
for row in range(4)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_matrix() -> Matrix4:
|
||||
return (
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _matrix_close(left: Matrix4, right: Matrix4, tolerance: float) -> bool:
|
||||
return all(
|
||||
abs(left[row][column] - right[row][column]) <= tolerance
|
||||
for row in range(4)
|
||||
for column in range(4)
|
||||
)
|
||||
|
||||
|
||||
def _matrix_close_row(
|
||||
left: tuple[float, float, float, float],
|
||||
right: tuple[float, float, float, float],
|
||||
tolerance: float,
|
||||
) -> bool:
|
||||
return all(abs(left[index] - right[index]) <= tolerance for index in range(4))
|
||||
|
||||
|
||||
def _matrix_as_lists(matrix: Matrix4) -> list[list[float]]:
|
||||
return [list(row) for row in matrix]
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
|
||||
ApplicationAuthorityLoader,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_file import (
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
CalibrationFileContent,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_mqtt import (
|
||||
FactoryCalibrationReadResult,
|
||||
ReviewedCalibrationMqttReader,
|
||||
)
|
||||
|
||||
DEVICE_CALIBRATION_SCHEMA_VERSION = "missioncore.device-calibration/v1alpha2"
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION = "missioncore.k1-calibration-snapshot/v1"
|
||||
CALIBRATION_CAPTURE_SUFFIX = "k1_factory_calibration"
|
||||
|
||||
CalibrationTransportFactory = Callable[[str, bool], ReviewedCalibrationMqttReader]
|
||||
|
||||
|
||||
class DeviceCalibrationSnapshotError(RuntimeError):
|
||||
"""A live factory-calibration snapshot could not be sealed safely."""
|
||||
|
||||
|
||||
class DeviceCalibrationSnapshotReader:
|
||||
"""Read two exact K1 files and seal an append-only private snapshot."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority_loader: ApplicationAuthorityLoader,
|
||||
*,
|
||||
compatibility_profile_id: str,
|
||||
transport_factory: CalibrationTransportFactory | None = None,
|
||||
) -> None:
|
||||
self._authority_loader = authority_loader
|
||||
self._compatibility_profile_id = compatibility_profile_id
|
||||
self._transport_factory = transport_factory or _default_transport_factory
|
||||
|
||||
def capture(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
evidence_root: Path,
|
||||
allow_device_ap: bool,
|
||||
) -> dict[str, object]:
|
||||
authority = self._authority_loader.load()
|
||||
transport = self._transport_factory(host, allow_device_ap)
|
||||
result = transport.read_factory_calibration(authority)
|
||||
return seal_factory_calibration_snapshot(
|
||||
result,
|
||||
evidence_root=evidence_root,
|
||||
compatibility_profile_id=self._compatibility_profile_id,
|
||||
)
|
||||
|
||||
|
||||
def unavailable_device_calibration_snapshot(
|
||||
compatibility_profile_id: str | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": DEVICE_CALIBRATION_SCHEMA_VERSION,
|
||||
"status": "unavailable",
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"device_internal_calibration": None,
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": "device-calibration-not-observed",
|
||||
}
|
||||
|
||||
|
||||
def seal_factory_calibration_snapshot(
|
||||
result: FactoryCalibrationReadResult,
|
||||
*,
|
||||
evidence_root: Path,
|
||||
compatibility_profile_id: str,
|
||||
captured_at: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
files_by_path = {item.path: item for item in result.files}
|
||||
if set(files_by_path) != {
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
}:
|
||||
raise DeviceCalibrationSnapshotError(
|
||||
"factory calibration result does not contain the exact two-file set"
|
||||
)
|
||||
normalized_calibration = parse_k1_factory_calibration(
|
||||
files_by_path[FACTORY_CAMERA_CALIBRATION_PATH].content,
|
||||
files_by_path[FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH].content,
|
||||
).normalized_profile()
|
||||
|
||||
observed_at = (captured_at or datetime.now(UTC)).astimezone(UTC)
|
||||
captured_at_utc = observed_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
snapshot_id = uuid4().hex
|
||||
content_identity = hashlib.sha256()
|
||||
content_identity.update(result.binding.vendor_device_id.encode("ascii"))
|
||||
content_identity.update(b"\x00")
|
||||
content_identity.update(result.binding.device_serial.encode("ascii"))
|
||||
for path in sorted(files_by_path):
|
||||
content_identity.update(b"\x00")
|
||||
content_identity.update(path.encode("utf-8"))
|
||||
content_identity.update(bytes.fromhex(files_by_path[path].content_sha256))
|
||||
|
||||
artifact_specs = (
|
||||
(
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
"camera.yaml",
|
||||
files_by_path[FACTORY_CAMERA_CALIBRATION_PATH],
|
||||
),
|
||||
(
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
files_by_path[FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH],
|
||||
),
|
||||
)
|
||||
artifact_documents = [
|
||||
_artifact_document(source_path, artifact_name, content)
|
||||
for source_path, artifact_name, content in artifact_specs
|
||||
]
|
||||
private_root = evidence_root.expanduser().resolve() / "private" / "device-calibration"
|
||||
_ensure_private_directory(private_root)
|
||||
stamp = observed_at.strftime("%Y%m%dT%H%M%SZ")
|
||||
final_name = f"{stamp}_{CALIBRATION_CAPTURE_SUFFIX}_{snapshot_id[:12]}"
|
||||
final_dir = private_root / final_name
|
||||
staging_dir = private_root / f".{final_name}.incomplete"
|
||||
try:
|
||||
staging_dir.mkdir(mode=0o700, exist_ok=False)
|
||||
for _source_path, artifact_name, content in artifact_specs:
|
||||
_write_exclusive(staging_dir / artifact_name, content.content)
|
||||
private_reference = str(final_dir)
|
||||
manifest = {
|
||||
"schema_version": CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
"snapshot_id": snapshot_id,
|
||||
"captured_at_utc": captured_at_utc,
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"content_identity_sha256": content_identity.hexdigest(),
|
||||
"device": {
|
||||
"vendor_device_id": result.binding.vendor_device_id,
|
||||
"device_serial": result.binding.device_serial,
|
||||
"device_model": result.binding.device_model,
|
||||
"platform_type": result.binding.device_type,
|
||||
"software_version": result.binding.software_version,
|
||||
"system_version": result.binding.system_version,
|
||||
"is_activated": result.binding.is_activated,
|
||||
},
|
||||
"source": {
|
||||
"transport": "mqtt-protobuf-read-only",
|
||||
"request_command": 5,
|
||||
"write_command_available": False,
|
||||
"automatic_retry": False,
|
||||
"path_allowlist": [
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
],
|
||||
"transport_snapshot": result.transport,
|
||||
},
|
||||
"artifacts": artifact_documents,
|
||||
"normalized_calibration": normalized_calibration,
|
||||
"storage": {
|
||||
"classification": "private-device-calibration",
|
||||
"append_only": True,
|
||||
"snapshot_path": private_reference,
|
||||
},
|
||||
}
|
||||
manifest_bytes = (
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||
).encode("utf-8")
|
||||
_write_exclusive(staging_dir / "manifest.json", manifest_bytes)
|
||||
os.rename(staging_dir, final_dir)
|
||||
_fsync_directory(private_root)
|
||||
except Exception:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(staging_dir)
|
||||
raise
|
||||
|
||||
return {
|
||||
"schema_version": DEVICE_CALIBRATION_SCHEMA_VERSION,
|
||||
"status": "available",
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"snapshot_id": snapshot_id,
|
||||
"captured_at_utc": captured_at_utc,
|
||||
"device_internal_calibration": {
|
||||
"source": "xgrids-factory-data-live-read",
|
||||
"device_serial": result.binding.device_serial,
|
||||
"firmware_version": result.binding.software_version,
|
||||
"content_identity_sha256": content_identity.hexdigest(),
|
||||
"documents": artifact_documents,
|
||||
"private_snapshot_path": str(final_dir),
|
||||
"normalized_calibration": normalized_calibration,
|
||||
"camera_stream_mapping": normalized_calibration["mapping_proof"],
|
||||
},
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": None,
|
||||
}
|
||||
|
||||
|
||||
def _default_transport_factory(host: str, allow_device_ap: bool) -> ReviewedCalibrationMqttReader:
|
||||
return ReviewedCalibrationMqttReader(host, allow_device_ap=allow_device_ap)
|
||||
|
||||
|
||||
def _artifact_document(
|
||||
source_path: str,
|
||||
artifact_name: str,
|
||||
content: CalibrationFileContent,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"source_path": source_path,
|
||||
"artifact_name": artifact_name,
|
||||
"sha256": content.content_sha256,
|
||||
"bytes": content.content_bytes,
|
||||
"encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
current = path
|
||||
while current.name in {"private", "device-calibration"}:
|
||||
with suppress(OSError):
|
||||
current.chmod(0o700)
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _write_exclusive(path: Path, payload: bytes) -> None:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -7,6 +7,7 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -52,6 +53,22 @@ class CameraProcessLease:
|
||||
failure_code: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommittedCameraSegment:
|
||||
"""A camera fragment observed only after its raw archive commit."""
|
||||
|
||||
source_id: CameraSourceId
|
||||
generation: int
|
||||
kind: CameraArchiveKind
|
||||
sequence: int
|
||||
host_epoch_ns: int
|
||||
host_monotonic_ns: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
CommittedCameraSegmentObserver = Callable[[CommittedCameraSegment], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CameraProducer:
|
||||
generation: int
|
||||
@@ -78,7 +95,13 @@ class XgridsK1CameraGateway:
|
||||
Outside an acquisition the legacy lazy-preview lifecycle remains available.
|
||||
"""
|
||||
|
||||
def __init__(self, repository_root: Path, plugin_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
repository_root: Path,
|
||||
plugin_id: str,
|
||||
*,
|
||||
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
|
||||
) -> None:
|
||||
self._repository_root = repository_root.resolve()
|
||||
self._plugin_id = plugin_id
|
||||
self._lock = threading.RLock()
|
||||
@@ -94,6 +117,8 @@ class XgridsK1CameraGateway:
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
self._error: dict[str, str] | None = None
|
||||
self._closed = False
|
||||
self._committed_segment_observer = committed_segment_observer
|
||||
self._committed_segment_observer_errors = 0
|
||||
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
@@ -148,6 +173,7 @@ class XgridsK1CameraGateway:
|
||||
"source": self._ffmpeg_source,
|
||||
},
|
||||
"error": dict(self._error) if self._error is not None else None,
|
||||
"derived_observer_errors": self._committed_segment_observer_errors,
|
||||
}
|
||||
|
||||
def select(self, source_id: CameraSourceId, target_host: str) -> dict[str, Any]:
|
||||
@@ -531,7 +557,7 @@ class XgridsK1CameraGateway:
|
||||
if archive is not None:
|
||||
try:
|
||||
# Source of record first; preview is always expendable.
|
||||
archive.append(kind, payload)
|
||||
committed = archive.append(kind, payload)
|
||||
except (CameraArchiveError, OSError, ValueError):
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
@@ -539,6 +565,26 @@ class XgridsK1CameraGateway:
|
||||
"Долговременная запись camera stream завершилась ошибкой.",
|
||||
)
|
||||
return False
|
||||
observer = self._committed_segment_observer
|
||||
if observer is not None:
|
||||
try:
|
||||
observer(
|
||||
CommittedCameraSegment(
|
||||
source_id=producer.source_id,
|
||||
generation=producer.generation,
|
||||
kind=kind,
|
||||
sequence=int(committed["sequence"]),
|
||||
host_epoch_ns=int(committed["host_epoch_ns"]),
|
||||
host_monotonic_ns=int(committed["host_monotonic_ns"]),
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Derived diagnostics can be dropped, but can never fail or
|
||||
# back-pressure the authoritative camera recording.
|
||||
with self._lock:
|
||||
self._committed_segment_observer_errors += 1
|
||||
self._revision += 1
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
|
||||
@@ -21,6 +21,8 @@ from k1link.compute import prepare_camera_compute_job
|
||||
from k1link.device_plugins.xgrids_k1.analyze import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
CalibratedOverlayExperimentError,
|
||||
run_calibrated_overlay_experiment,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
|
||||
@@ -31,6 +33,10 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
WriteMode,
|
||||
provision_wifi_once,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.firmware_credential import (
|
||||
FirmwareCredentialError,
|
||||
import_k1_fw302_ap_credential,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||
DEFAULT_MAX_MESSAGE_BYTES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
@@ -151,6 +157,53 @@ def authority_provision(
|
||||
)
|
||||
|
||||
|
||||
@authority_app.command("import-k1-fw302-ap")
|
||||
def import_k1_fw302_ap(
|
||||
firmware: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
exists=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Official XGRIDS K1 3.0.2 full firmware archive.",
|
||||
),
|
||||
],
|
||||
confirm_reviewed_firmware: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--confirm-reviewed-firmware",
|
||||
help="Confirm offline import from the exact reviewed official artifact.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Install the exact K1 AP material in the OS credential store."""
|
||||
|
||||
if not confirm_reviewed_firmware:
|
||||
console.print(
|
||||
"[red]Firmware credential import not confirmed.[/red] "
|
||||
"Add --confirm-reviewed-firmware for the reviewed official 3.0.2 image."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
helper = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift"
|
||||
)
|
||||
try:
|
||||
result = import_k1_fw302_ap_credential(firmware, helper)
|
||||
except (FirmwareCredentialError, OSError, ValueError) as exc:
|
||||
console.print(f"[red]Firmware credential import failed:[/red] {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
console.print(
|
||||
"[green]Exact firmware credential provider installed.[/green] "
|
||||
f"provider={result.provider_id!r}; adapter={result.host_adapter!r}; "
|
||||
"secret_exposed=false; no K1 command was sent."
|
||||
)
|
||||
|
||||
|
||||
def _default_route_interface() -> str | None:
|
||||
output = _command_output(["route", "-n", "get", "default"])
|
||||
if output is None:
|
||||
@@ -463,7 +516,7 @@ def net_mqtt_capture(
|
||||
] = 1883,
|
||||
duration: Annotated[
|
||||
float,
|
||||
typer.Option(min=1.0, max=3600.0, help="Capture duration after SUBACK, in seconds."),
|
||||
typer.Option(min=1.0, help="Capture duration after SUBACK, in seconds."),
|
||||
] = 60.0,
|
||||
max_message_bytes: Annotated[
|
||||
int,
|
||||
@@ -587,6 +640,90 @@ def analyze_mqtt_streams(
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@analyze_app.command("calibrated-overlay")
|
||||
def analyze_calibrated_overlay(
|
||||
session_root: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--session-root",
|
||||
exists=True,
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Sealed observation session containing MQTT and camera evidence.",
|
||||
),
|
||||
],
|
||||
calibration_snapshot: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--calibration-snapshot",
|
||||
exists=True,
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Private physical K1 factory-calibration snapshot directory.",
|
||||
),
|
||||
],
|
||||
out_root: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--out-root",
|
||||
help="Mission Core evidence root; the result is sealed below private/.",
|
||||
),
|
||||
],
|
||||
source_id: Annotated[
|
||||
str,
|
||||
typer.Option("--source", help="Canonical archived K1 main-camera source id."),
|
||||
] = "sensor.camera.right",
|
||||
video_offsets: Annotated[
|
||||
list[float] | None,
|
||||
typer.Option(
|
||||
"--video-offset",
|
||||
help="Repeat for each diagnostic second inside the camera epoch.",
|
||||
),
|
||||
] = None,
|
||||
temporal_offset_seconds: Annotated[
|
||||
float,
|
||||
typer.Option(
|
||||
"--temporal-offset",
|
||||
min=-5.0,
|
||||
max=5.0,
|
||||
help="Explicit LiDAR minus camera host-arrival offset for experiments.",
|
||||
),
|
||||
] = 0.0,
|
||||
) -> None:
|
||||
"""Build a read-only LiDAR→KB4→camera diagnostic from sealed evidence."""
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
console.print("[red]Calibrated overlay failed:[/red] ffmpeg is unavailable")
|
||||
raise typer.Exit(code=2)
|
||||
offsets = tuple(video_offsets or (60.0, 180.0, 300.0, 420.0))
|
||||
try:
|
||||
result = run_calibrated_overlay_experiment(
|
||||
session_root=session_root,
|
||||
calibration_snapshot_root=calibration_snapshot,
|
||||
source_id=source_id,
|
||||
video_offsets_seconds=offsets,
|
||||
output_root=out_root,
|
||||
ffmpeg_path=Path(ffmpeg),
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
except (CalibratedOverlayExperimentError, OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f"[red]Calibrated overlay failed:[/red] {type(exc).__name__}: {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
console.print(
|
||||
f"[green]Calibrated diagnostic ready:[/green] {result.experiment_id}; "
|
||||
f"frames: {result.frame_count}; source: {result.source_id}"
|
||||
)
|
||||
console.print(f"Calibration: {result.calibration_content_identity}")
|
||||
console.print(f"Rerun: {result.rerun_path}")
|
||||
console.print(f"Mosaic: {result.mosaic_path}")
|
||||
console.print("P0 remains open until static-landmark reprojection is measured.")
|
||||
|
||||
|
||||
@compute_app.command("prepare-camera-job")
|
||||
def prepare_camera_job(
|
||||
session_root: Annotated[
|
||||
|
||||
@@ -32,20 +32,36 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.compute.live_perception import LivePerceptionIngress
|
||||
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
device_ap_activation_session,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
provision_wifi_once,
|
||||
read_wifi_status_once,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
DeviceCalibrationSnapshotReader,
|
||||
unavailable_device_calibration_snapshot,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.camera import (
|
||||
CAMERA_EXCLUSIVE_GROUP,
|
||||
CAMERA_SOURCE_LABELS,
|
||||
CAMERA_SOURCE_PATHS,
|
||||
CameraSourceId,
|
||||
CommittedCameraSegment,
|
||||
XgridsK1CameraGateway,
|
||||
build_xgrids_k1_camera_router,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.macos_wifi import associate_with_wifi_once
|
||||
from k1link.device_plugins.xgrids_k1.firmware_credential import (
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.live_perception_shadow import (
|
||||
build_live_perception_shadow_router,
|
||||
ensure_live_shadow_token,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
@@ -71,11 +87,19 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
LiveModelingControlSafety,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.quick_connect_profile import (
|
||||
quick_connect_host_profile_id,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.viewer.runtime import (
|
||||
VisualizationRuntime,
|
||||
new_live_session_dir,
|
||||
)
|
||||
from k1link.host_network import (
|
||||
HostWifiProfileError,
|
||||
associate_with_wifi_profile_once,
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
)
|
||||
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||
from k1link.viewer.metrics import BridgeMetrics
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
@@ -203,8 +227,8 @@ class CompatibilityAttestationRequest(StrictRequest):
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr = Field(min_length=1, max_length=256)
|
||||
ssid: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
password: SecretStr | None = Field(default=None, min_length=1, max_length=256)
|
||||
connection_mode: ConnectionMode = "bridge"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
@@ -212,24 +236,35 @@ class ConnectRequest(StrictRequest):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_connection_topology(self) -> Self:
|
||||
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
|
||||
if self.compatibility_attestation.topology != expected:
|
||||
raise ValueError(
|
||||
f"connection_mode={self.connection_mode} requires topology={expected}"
|
||||
)
|
||||
if self.connection_mode == "quick-connect":
|
||||
if self.ssid is not None or self.password is not None:
|
||||
raise ValueError(
|
||||
"Quick Connect resolves its credential from the host Wi-Fi profile"
|
||||
)
|
||||
return self
|
||||
if self.ssid is None or self.password is None:
|
||||
raise ValueError("SSID and Wi-Fi password are required for this connection mode")
|
||||
if not 1 <= len(self.ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(self.password.get_secret_value().encode("utf-8")) <= 64:
|
||||
raise ValueError(
|
||||
"Wi-Fi password must contain between 1 and 64 UTF-8 bytes"
|
||||
)
|
||||
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
|
||||
if self.compatibility_attestation.topology != expected:
|
||||
raise ValueError(
|
||||
f"connection_mode={self.connection_mode} requires topology={expected}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class LiveRequest(StrictRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
# Omitted means run until the operator explicitly stops the acquisition.
|
||||
# A positive value remains available to compatibility clients that need a
|
||||
# bounded capture, but there is no application-level maximum.
|
||||
duration_seconds: float | None = Field(default=None, ge=1.0, allow_inf_nan=False)
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
|
||||
@field_validator("project_name")
|
||||
@@ -269,7 +304,7 @@ class PrepareAcquisitionRequest(OperationContextRequest):
|
||||
mount_type: Literal["handheld"] = "handheld"
|
||||
gnss_mode: Literal["none"] = "none"
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
duration_seconds: float | None = Field(default=None, ge=1.0, allow_inf_nan=False)
|
||||
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
||||
evidence_policy: Literal["required", "best-effort", "disabled"] = "required"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
@@ -322,6 +357,9 @@ class ViewerSettingsRequest(StrictRequest):
|
||||
show_points: bool = True
|
||||
show_trajectory: bool = True
|
||||
show_grid: bool = True
|
||||
show_detections_2d: bool = False
|
||||
show_segmentation: bool = False
|
||||
show_cuboids_3d: bool = False
|
||||
|
||||
|
||||
class ShadowApplicationControlArmRequest(StrictRequest):
|
||||
@@ -354,12 +392,14 @@ class XgridsK1CompatibilityService:
|
||||
repository_root: Path,
|
||||
*,
|
||||
application_authority_loader: ApplicationAuthorityLoader | None = None,
|
||||
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
|
||||
) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
|
||||
self._lock = threading.Lock()
|
||||
self._acquisition_lifecycle_gate = threading.RLock()
|
||||
self._provisioning_gate = threading.Lock()
|
||||
self._calibration_gate = threading.Lock()
|
||||
self._provisioning_active = False
|
||||
self._fingerprint_key = secrets.token_bytes(32)
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
@@ -391,9 +431,22 @@ class XgridsK1CompatibilityService:
|
||||
# The host-owned visual runtime receives the vendor normalizer
|
||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||
self._modeling_control_safety = LiveModelingControlSafety()
|
||||
self.live_perception_ingress = LivePerceptionIngress()
|
||||
(
|
||||
self.live_perception_token_path,
|
||||
self._live_perception_token,
|
||||
) = ensure_live_shadow_token(self.repository_root)
|
||||
authority_loader = (
|
||||
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
|
||||
)
|
||||
self._calibration_snapshot_reader = (
|
||||
calibration_snapshot_reader
|
||||
or DeviceCalibrationSnapshotReader(
|
||||
authority_loader,
|
||||
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||
)
|
||||
)
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(None)
|
||||
self._application_control = DormantApplicationControlCoordinator(
|
||||
authority_loader,
|
||||
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
|
||||
@@ -409,6 +462,7 @@ class XgridsK1CompatibilityService:
|
||||
self.camera_preview = XgridsK1CameraGateway(
|
||||
self.repository_root,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
committed_segment_observer=self._observe_committed_camera_segment,
|
||||
)
|
||||
|
||||
def _application_control_transport(self, host: str) -> ReviewedApplicationMqttTransport:
|
||||
@@ -445,6 +499,7 @@ class XgridsK1CompatibilityService:
|
||||
if self._compatibility_attestation is not None
|
||||
else None
|
||||
)
|
||||
device_calibration = dict(self._device_calibration)
|
||||
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
||||
if acquisition is not None:
|
||||
acquisition["project_name"] = self._acquisition_project_name
|
||||
@@ -487,6 +542,8 @@ class XgridsK1CompatibilityService:
|
||||
active_profile_id = (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None
|
||||
)
|
||||
if device_calibration.get("status") != "available":
|
||||
device_calibration = unavailable_device_calibration_snapshot(active_profile_id)
|
||||
active_control = application_control_session["state"] not in {
|
||||
"idle",
|
||||
"completed",
|
||||
@@ -559,8 +616,9 @@ class XgridsK1CompatibilityService:
|
||||
device_session_id,
|
||||
camera_preview,
|
||||
),
|
||||
"device_calibration": _device_calibration_snapshot(active_profile_id),
|
||||
"device_calibration": device_calibration,
|
||||
"camera_preview": camera_preview,
|
||||
"live_perception_shadow": self.live_perception_ingress.snapshot(),
|
||||
"acquisition": acquisition,
|
||||
"operations": operation_documents,
|
||||
"last_operation": operation_documents[-1] if operation_documents else None,
|
||||
@@ -577,6 +635,11 @@ class XgridsK1CompatibilityService:
|
||||
"frame_rate_hz": metrics["pcl_fps"],
|
||||
"point_count": metrics["last_point_count"],
|
||||
"dropped_preview_frames": metrics["preview_dropped"],
|
||||
"ai_end_to_end_ms": metrics.get("perception_end_to_end_ms"),
|
||||
"ai_end_to_end_p95_ms": metrics.get("perception_end_to_end_p95_ms"),
|
||||
"ai_frame_rate_hz": metrics.get("perception_fps"),
|
||||
"ai_dropped_frames": metrics.get("perception_dropped"),
|
||||
"ai_stale_ms": metrics.get("perception_stale_ms"),
|
||||
**metrics,
|
||||
},
|
||||
}
|
||||
@@ -639,17 +702,45 @@ class XgridsK1CompatibilityService:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
# Unwrap once at the network service boundary. The plain value is kept
|
||||
# only in this stack frame, included in a keyed request digest, and
|
||||
# passed either to the reviewed BLE write or to the short-lived macOS
|
||||
# CoreWLAN helper over stdin; it is never journaled.
|
||||
password = request.password.get_secret_value()
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
selected_device = next(
|
||||
item for item in self.state()["devices"] if item["device_id"] == request.device_id
|
||||
)
|
||||
selected_device_name = str(selected_device.get("name") or "").strip()
|
||||
if quick_connect and not selected_device_name:
|
||||
raise ValueError(
|
||||
"выбранный BLE-кандидат не сообщил имя точки доступа; "
|
||||
"Quick Connect остановлен без команды устройству"
|
||||
)
|
||||
quick_connect_profile_id = (
|
||||
quick_connect_host_profile_id(selected_device_name) if quick_connect else None
|
||||
)
|
||||
host_wifi_helper_path = (
|
||||
self.repository_root
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift"
|
||||
)
|
||||
# Bridge and Direct Connect unwrap once at the BLE service boundary.
|
||||
# Quick Connect carries no browser/API credential. It first sends the
|
||||
# reviewed fixed AP-enable command, then the host-network adapter uses
|
||||
# the selected device's advertised name as its exact SSID and resolves
|
||||
# a device-scoped profile inside the OS credential store.
|
||||
password = (
|
||||
""
|
||||
if request.password is None
|
||||
else request.password.get_secret_value()
|
||||
)
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
ACTION_NETWORK_PROVISION,
|
||||
{
|
||||
"device_id": request.device_id,
|
||||
"ssid": request.ssid,
|
||||
"password": password,
|
||||
"password": password if not quick_connect else None,
|
||||
"host_wifi_profile": (
|
||||
quick_connect_profile_id if quick_connect else None
|
||||
),
|
||||
"connection_mode": request.connection_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
@@ -662,7 +753,7 @@ class XgridsK1CompatibilityService:
|
||||
idempotency_key=request.idempotency_key,
|
||||
device_id=self._device_id,
|
||||
device_session_id=self._device_session_id,
|
||||
deadline_seconds=60.0,
|
||||
deadline_seconds=240.0,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if not created:
|
||||
@@ -688,8 +779,34 @@ class XgridsK1CompatibilityService:
|
||||
|
||||
session_dir: Path | None = None
|
||||
network_change_attempted = False
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
operation_stage = (
|
||||
"device-ap-activation" if quick_connect else "ble-provisioning-write"
|
||||
)
|
||||
try:
|
||||
if quick_connect:
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "host-wifi-profile-preflight"
|
||||
self._set_operation(
|
||||
"credential_preflight",
|
||||
"Проверяем локальный профиль выбранного K1 до команды устройству.",
|
||||
)
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.host_wifi_profile_preflight",
|
||||
)
|
||||
profile_preflight = await asyncio.to_thread(
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
host_wifi_helper_path,
|
||||
quick_connect_profile_id,
|
||||
selected_device_name,
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
if not profile_preflight["available"]:
|
||||
raise HostWifiProfileError("credential-source-unavailable")
|
||||
|
||||
with self._lock:
|
||||
active_acquisition = self._acquisition
|
||||
if (
|
||||
@@ -708,6 +825,7 @@ class XgridsK1CompatibilityService:
|
||||
self._k1_ip = None
|
||||
self._connection_mode = None
|
||||
self._compatibility_attestation = None
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(None)
|
||||
self._device_session_id = None
|
||||
self._device_session_opened_at = None
|
||||
self._connection_verification = {
|
||||
@@ -725,7 +843,7 @@ class XgridsK1CompatibilityService:
|
||||
self.camera_preview.stop_current()
|
||||
|
||||
operation_message = (
|
||||
"Подключаем этот Mac к точке доступа выбранного K1 одним запросом."
|
||||
"Включаем точку доступа выбранного K1 и подключаем к ней управляющее устройство."
|
||||
if quick_connect
|
||||
else "Передаём устройству настройки Wi-Fi одним подтверждённым запросом."
|
||||
)
|
||||
@@ -740,38 +858,102 @@ class XgridsK1CompatibilityService:
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=(
|
||||
"host-wifi-association" if quick_connect else "ble-provisioning-write"
|
||||
),
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.running",
|
||||
)
|
||||
network_change_attempted = True
|
||||
if quick_connect:
|
||||
started_at = _utc_now_iso()
|
||||
association = await asyncio.to_thread(
|
||||
associate_with_wifi_once,
|
||||
self.repository_root
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift",
|
||||
request.ssid,
|
||||
password,
|
||||
timeout_seconds=45.0,
|
||||
)
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "device-ap-activation"
|
||||
async with device_ap_activation_session(
|
||||
request.device_id,
|
||||
timeout_seconds=15.0,
|
||||
write_mode="auto",
|
||||
) as activation:
|
||||
write_json_atomic(
|
||||
session_dir / "ap-activation.redacted.json",
|
||||
activation,
|
||||
)
|
||||
if not activation["ready_observed"]:
|
||||
raise RuntimeError(
|
||||
"K1 не подтвердил готовность точки доступа; "
|
||||
"системное подключение Wi-Fi не запускалось"
|
||||
)
|
||||
operation_stage = "host-wifi-association"
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.host_wifi_association",
|
||||
)
|
||||
# Keep the same BLE connection alive through the native
|
||||
# CoreWLAN discovery/association handoff, as the reviewed
|
||||
# LixelGO Quick Connect flow does.
|
||||
try:
|
||||
association = await asyncio.to_thread(
|
||||
associate_with_wifi_profile_once,
|
||||
host_wifi_helper_path,
|
||||
quick_connect_profile_id,
|
||||
selected_device_name,
|
||||
scan_timeout_seconds=15.0,
|
||||
timeout_seconds=180.0,
|
||||
)
|
||||
except HostWifiProfileError as exc:
|
||||
write_json_atomic(
|
||||
session_dir / "host-wifi-association.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"completed_at_utc": _utc_now_iso(),
|
||||
"adapter": "CoreWLAN",
|
||||
"outcome": "failed",
|
||||
"reason_code": exc.reason_code,
|
||||
"scan_attempt_count": exc.scan_attempt_count,
|
||||
"scan_elapsed_ms": exc.scan_elapsed_ms,
|
||||
},
|
||||
)
|
||||
raise
|
||||
completed_at = _utc_now_iso()
|
||||
ipv4: str | None = AP_FALLBACK_IPV4
|
||||
connection_manifest: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"started_at_utc": activation["started_at_utc"],
|
||||
"completed_at_utc": completed_at,
|
||||
"operation": "single_corewlan_k1_ap_association",
|
||||
"operation": "single_k1_ap_activation_and_host_profile_association",
|
||||
"connection_mode": request.connection_mode,
|
||||
"topology": request.compatibility_attestation.topology,
|
||||
"outcome": association["outcome"],
|
||||
"credentials_persisted_by_connector": False,
|
||||
"device_ap_activation_profile_id": activation["profile_id"],
|
||||
"device_ap_activation_outcome": activation["outcome"],
|
||||
"device_ap_ready_observed": activation["ready_observed"],
|
||||
"device_ap_activation_write_performed": activation["write_performed"],
|
||||
"device_ap_activation_write_mode": activation["write_mode"],
|
||||
"host_wifi_adapter": association["adapter"],
|
||||
"host_wifi_profile_id": quick_connect_profile_id,
|
||||
"host_wifi_profile_ready_before_device_write": profile_preflight[
|
||||
"available"
|
||||
],
|
||||
"host_wifi_profile_preflight_adapter": profile_preflight["adapter"],
|
||||
"host_wifi_profile_materialized_before_device_write": profile_preflight[
|
||||
"profile_enrolled"
|
||||
],
|
||||
"credential_provider_id": K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
"credential_provider_source": profile_preflight[
|
||||
"credential_source"
|
||||
],
|
||||
"host_wifi_profile_enrolled_now": association["profile_enrolled"],
|
||||
"host_wifi_scan_attempt_count": association["scan_attempt_count"],
|
||||
"host_wifi_scan_elapsed_ms": association["scan_elapsed_ms"],
|
||||
"host_wifi_credential_source": association["credential_source"],
|
||||
"device_ap_ssid_source": "selected-ble-advertised-name",
|
||||
"credentials_resolved_by_plugin": (
|
||||
profile_preflight["credential_source"]
|
||||
== "exact-firmware-profile"
|
||||
),
|
||||
"credentials_persisted_by_host_adapter": True,
|
||||
}
|
||||
else:
|
||||
if request.ssid is None:
|
||||
raise RuntimeError("SSID отсутствует после проверки запроса")
|
||||
result = await provision_wifi_once(
|
||||
request.device_id,
|
||||
request.ssid,
|
||||
@@ -825,6 +1007,9 @@ class XgridsK1CompatibilityService:
|
||||
self._compatibility_attestation = _attestation_snapshot(
|
||||
request.compatibility_attestation
|
||||
)
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
self._connection_verification = {
|
||||
"status": "not-probed",
|
||||
"endpoint_validation": "not-performed",
|
||||
@@ -863,7 +1048,7 @@ class XgridsK1CompatibilityService:
|
||||
self._operations.transition_if_pending(
|
||||
operation.operation_id,
|
||||
"failed",
|
||||
stage_code="failed",
|
||||
stage_code=f"{operation_stage}-failed",
|
||||
message_code="network.provision.failed",
|
||||
error=_operation_error(
|
||||
exc,
|
||||
@@ -896,26 +1081,143 @@ class XgridsK1CompatibilityService:
|
||||
message: StreamMessage,
|
||||
metrics: BridgeMetrics,
|
||||
) -> bool:
|
||||
self._observe_live_perception_mqtt(message)
|
||||
if self._modeling_control_safety.observe(message, metrics):
|
||||
return True
|
||||
return observe_modeling_report(message, metrics)
|
||||
|
||||
def _observe_live_perception_mqtt(self, message: StreamMessage) -> None:
|
||||
if message.source != "live_mqtt":
|
||||
return
|
||||
if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud":
|
||||
modality: Literal["lidar", "pose"] = "lidar"
|
||||
elif message.topic.endswith("/lio_pose") or message.topic == "RealtimePath":
|
||||
modality = "pose"
|
||||
else:
|
||||
return
|
||||
self.live_perception_ingress.publish(
|
||||
modality=modality,
|
||||
source_id=message.topic,
|
||||
source_sequence=message.sequence,
|
||||
captured_at_epoch_ns=message.received_at_epoch_ns,
|
||||
received_monotonic_ns=(
|
||||
message.received_monotonic_ns
|
||||
if message.received_monotonic_ns is not None
|
||||
else time.monotonic_ns()
|
||||
),
|
||||
payload=message.payload,
|
||||
)
|
||||
|
||||
def _observe_committed_camera_segment(
|
||||
self,
|
||||
segment: CommittedCameraSegment,
|
||||
) -> None:
|
||||
self.live_perception_ingress.publish(
|
||||
modality="camera-init" if segment.kind == "init" else "camera-frame",
|
||||
source_id=segment.source_id,
|
||||
source_sequence=segment.sequence,
|
||||
captured_at_epoch_ns=segment.host_epoch_ns,
|
||||
received_monotonic_ns=segment.host_monotonic_ns,
|
||||
payload=segment.payload,
|
||||
)
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]:
|
||||
"""Validate the recorded endpoint only; no network packet is emitted."""
|
||||
"""Refresh the session-scoped DHCP address from the read-only BLE status."""
|
||||
|
||||
self._refresh_live_lan_address()
|
||||
return self.state()
|
||||
|
||||
def _refresh_live_lan_address(self) -> str:
|
||||
with self._lock:
|
||||
selected_device_id = self._selected_device_id
|
||||
connection_mode = self._connection_mode
|
||||
acquisition = self._acquisition
|
||||
current_target = self._k1_ip
|
||||
if selected_device_id is None or connection_mode is None:
|
||||
raise ValueError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError(
|
||||
"нельзя менять DHCP-привязку во время активной acquisition-сессии"
|
||||
)
|
||||
control_state = str(self._application_control_session.snapshot()["state"])
|
||||
if control_state not in {"idle", "completed", "closed", "failed"}:
|
||||
raise RuntimeError("нельзя менять DHCP-привязку при открытой control-сессии")
|
||||
if connection_mode == "quick-connect":
|
||||
if current_target != AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("Quick Connect потерял фиксированный адрес точки доступа K1")
|
||||
return AP_FALLBACK_IPV4
|
||||
|
||||
status_read = asyncio.run(
|
||||
read_wifi_status_once(selected_device_id, timeout_seconds=20.0)
|
||||
)
|
||||
observed_target = status_read["status"]["ipv4"]
|
||||
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес через BLE status")
|
||||
target = validate_private_ipv4(observed_target)
|
||||
if _target_is_local_ipv4(target):
|
||||
raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру")
|
||||
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
if target is None:
|
||||
raise ValueError("у плагина нет подтверждённого локального адреса устройства")
|
||||
validate_private_ipv4(target)
|
||||
with self._lock:
|
||||
if (
|
||||
self._selected_device_id != selected_device_id
|
||||
or self._connection_mode != connection_mode
|
||||
):
|
||||
raise RuntimeError("выбранное подключение K1 изменилось во время DHCP refresh")
|
||||
address_changed = self._k1_ip != target
|
||||
self._k1_ip = target
|
||||
if address_changed:
|
||||
self._device_session_id = new_device_session_id()
|
||||
self._device_session_opened_at = _utc_now_iso()
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
self._connection_verification = {
|
||||
"status": "endpoint-valid",
|
||||
"endpoint_validation": "private-ip-syntax-only",
|
||||
"network_reachability": "unknown",
|
||||
"observed_at": _utc_now_iso(),
|
||||
"status": "live-address-observed",
|
||||
"endpoint_validation": "ble-wifi-status-read",
|
||||
"network_reachability": "not-probed",
|
||||
"address_changed": address_changed,
|
||||
"previous_address_present": current_target is not None,
|
||||
"write_performed": False,
|
||||
"observed_at": status_read["observed_at_utc"],
|
||||
}
|
||||
return self.state()
|
||||
return target
|
||||
|
||||
def read_device_calibration_snapshot(self) -> dict[str, Any]:
|
||||
"""Read and seal the two reviewed factory YAML files without device mutation."""
|
||||
|
||||
if not self._calibration_gate.acquire(blocking=False):
|
||||
raise RuntimeError("чтение заводской калибровки уже выполняется")
|
||||
try:
|
||||
self._refresh_live_lan_address()
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
connection_mode = self._connection_mode
|
||||
attestation = self._compatibility_attestation
|
||||
if target is None or connection_mode is None:
|
||||
raise ValueError("у плагина нет подтверждённого локального адреса K1")
|
||||
if attestation is None:
|
||||
raise ValueError(
|
||||
"для чтения калибровки сначала должен быть выбран точный профиль K1 FW 3.0.2"
|
||||
)
|
||||
snapshot = self._calibration_snapshot_reader.capture(
|
||||
host=target,
|
||||
evidence_root=self.evidence_root,
|
||||
allow_device_ap=connection_mode == "quick-connect",
|
||||
)
|
||||
with self._lock:
|
||||
if (
|
||||
self._k1_ip != target
|
||||
or self._connection_mode != connection_mode
|
||||
or self._compatibility_attestation != attestation
|
||||
):
|
||||
raise RuntimeError(
|
||||
"подключение K1 изменилось во время чтения; "
|
||||
"снимок сохранён, но не активирован"
|
||||
)
|
||||
self._device_calibration = dict(snapshot)
|
||||
return dict(snapshot)
|
||||
finally:
|
||||
self._calibration_gate.release()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def open_application_control_session(
|
||||
@@ -927,6 +1229,8 @@ class XgridsK1CompatibilityService:
|
||||
with self._lock:
|
||||
if self._provisioning_active:
|
||||
raise RuntimeError("нельзя открывать control-сессию во время настройки Wi-Fi")
|
||||
self._refresh_live_lan_address()
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
attestation = self._compatibility_attestation
|
||||
acquisition = self._acquisition
|
||||
@@ -1018,6 +1322,8 @@ class XgridsK1CompatibilityService:
|
||||
"plugin-commanded" if control_state == "workspace-ready" else "operator-manual"
|
||||
)
|
||||
requested_streams = _validated_requested_streams(request)
|
||||
if request.host is None and control_mode == "operator-manual":
|
||||
self._refresh_live_lan_address()
|
||||
target = request.host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(
|
||||
@@ -1235,6 +1541,7 @@ class XgridsK1CompatibilityService:
|
||||
lease.release()
|
||||
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
|
||||
self._acquisition_session_lease = lease
|
||||
self.live_perception_ingress.begin_session(out_dir.name)
|
||||
self._modeling_control_safety.reset()
|
||||
self.runtime.start_live(
|
||||
acquisition.target_host,
|
||||
@@ -1621,7 +1928,7 @@ class XgridsK1CompatibilityService:
|
||||
self,
|
||||
project_name: str,
|
||||
host: str | None,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Deprecated compatibility shim over prepare + operator-manual start."""
|
||||
@@ -1770,6 +2077,11 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
self._terminalize_acquisition_operations_on_shutdown(terminal_error)
|
||||
finally:
|
||||
with self._lock:
|
||||
out_dir = self._acquisition_out_dir
|
||||
if out_dir is not None:
|
||||
self.live_perception_ingress.end_session(out_dir.name)
|
||||
self.live_perception_ingress.close()
|
||||
if terminal_error is None:
|
||||
self._release_acquisition_session_lease()
|
||||
if terminal_error is not None:
|
||||
@@ -1786,6 +2098,9 @@ class XgridsK1CompatibilityService:
|
||||
show_points=request.show_points,
|
||||
show_trajectory=request.show_trajectory,
|
||||
show_grid=request.show_grid,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
)
|
||||
)
|
||||
return self.state()
|
||||
@@ -1826,6 +2141,8 @@ class XgridsK1CompatibilityService:
|
||||
camera_status: Literal["complete", "interrupted", "failed"],
|
||||
camera_failure_code: str | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
out_dir = self._acquisition_out_dir
|
||||
camera_error: Exception | None = None
|
||||
runtime_error: Exception | None = None
|
||||
try:
|
||||
@@ -1853,6 +2170,8 @@ class XgridsK1CompatibilityService:
|
||||
self._seal_acquisition_capture_clock()
|
||||
cleanup_complete = True
|
||||
finally:
|
||||
if out_dir is not None:
|
||||
self.live_perception_ingress.end_session(out_dir.name)
|
||||
if cleanup_complete:
|
||||
self._release_acquisition_session_lease()
|
||||
|
||||
@@ -2333,6 +2652,8 @@ class XgridsK1ServicePort(Protocol):
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]: ...
|
||||
|
||||
def read_device_calibration_snapshot(self) -> dict[str, Any]: ...
|
||||
|
||||
def open_application_control_session(
|
||||
self,
|
||||
request: OpenApplicationControlSessionRequest,
|
||||
@@ -2364,7 +2685,7 @@ class XgridsK1ServicePort(Protocol):
|
||||
self,
|
||||
project_name: str,
|
||||
host: str | None,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@@ -2442,13 +2763,17 @@ class XgridsK1PluginFacade:
|
||||
if action_id in {
|
||||
ACTION_DEVICE_INSPECT,
|
||||
ACTION_SENSOR_CATALOG_READ,
|
||||
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
|
||||
ACTION_ACQUISITION_STATE_READ,
|
||||
}:
|
||||
EmptyRequest.model_validate(payload)
|
||||
if action_id == ACTION_DEVICE_INSPECT:
|
||||
return await asyncio.to_thread(self.service.inspect_device)
|
||||
return await asyncio.to_thread(self.service.state)
|
||||
if action_id == ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.read_device_calibration_snapshot
|
||||
)
|
||||
if action_id == ACTION_NETWORK_PROVISION:
|
||||
connect_request = ConnectRequest.model_validate(payload)
|
||||
return await self.service.connect(connect_request)
|
||||
@@ -2567,9 +2892,10 @@ def _operation_error(
|
||||
side_effect_status: Literal["none", "possible", "confirmed", "unknown"],
|
||||
safe_to_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
reason_code = getattr(exc, "reason_code", None)
|
||||
return {
|
||||
"category": category,
|
||||
"code": type(exc).__name__,
|
||||
"code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__,
|
||||
"retryable": False,
|
||||
"safe_to_retry": safe_to_retry,
|
||||
"side_effect_status": side_effect_status,
|
||||
@@ -2716,17 +3042,6 @@ def _sensor_catalog(
|
||||
}
|
||||
|
||||
|
||||
def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.device-calibration/v1alpha2",
|
||||
"status": "unavailable",
|
||||
"compatibility_profile_id": active_profile_id,
|
||||
"device_internal_calibration": None,
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": "device-calibration-not-observed",
|
||||
}
|
||||
|
||||
|
||||
def _new_operation_session_dir(sessions_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = sessions_root / f"{stamp}_{suffix}"
|
||||
@@ -2827,6 +3142,12 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
||||
service.camera_preview,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
),
|
||||
build_live_perception_shadow_router(
|
||||
service.live_perception_ingress,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
bearer_token=service._live_perception_token,
|
||||
result_receiver=service.runtime.publish_perception_result,
|
||||
),
|
||||
),
|
||||
observation=build_xgrids_k1_observation(repository_root),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import tarfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
from k1link.host_network.wifi import (
|
||||
HostWifiCredentialMaterialStoreResult,
|
||||
store_wifi_credential_material,
|
||||
)
|
||||
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID = (
|
||||
"xgrids.lixelkity-k1.quick-connect.fw-3.0.2.official-firmware.v1"
|
||||
)
|
||||
K1_FW302_OFFICIAL_ARCHIVE_SHA256 = (
|
||||
"e5830feae54d586cdeda2824495d08598920dc9cf4541059d01f0efeb858a750"
|
||||
)
|
||||
K1_FW302_OUTER_MEMBER = "upgrade.tar.gz"
|
||||
K1_FW302_RK_IMAGE_MEMBER = (
|
||||
"upgrade/rk/rk_normal/"
|
||||
"OTA-BOOT-ROOTFS-APP-V3.0.2-20250624.153447.img"
|
||||
)
|
||||
K1_FW302_APPS_OFFSET = 4_338_829_862
|
||||
K1_FW302_APPS_SIZE = 230_801_408
|
||||
|
||||
_AP_PSK_DECLARATION = b"nmcli conn modify WIFI_AP 802-11-wireless-security.psk "
|
||||
_READ_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
_SCAN_OVERLAP = len(_AP_PSK_DECLARATION) + 128
|
||||
|
||||
|
||||
class FirmwareCredentialError(RuntimeError):
|
||||
"""An exact firmware artifact cannot provide the reviewed credential."""
|
||||
|
||||
|
||||
class SecretBuffer:
|
||||
"""Short-lived credential bytes whose repr and str are always redacted."""
|
||||
|
||||
__slots__ = ("_value",)
|
||||
|
||||
def __init__(self, value: bytearray) -> None:
|
||||
self._value = value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SecretBuffer(<redacted>)"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def reveal_ascii(self) -> str:
|
||||
return self._value.decode("ascii")
|
||||
|
||||
def zeroize(self) -> None:
|
||||
self._value[:] = b"\x00" * len(self._value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareCredentialImportResult:
|
||||
provider_id: str
|
||||
firmware_sha256: str
|
||||
host_adapter: str
|
||||
outcome: str
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(_READ_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _discard_exact(stream: IO[bytes], size: int) -> None:
|
||||
remaining = size
|
||||
while remaining:
|
||||
chunk = stream.read(min(_READ_CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
raise FirmwareCredentialError("firmware image ended before the apps partition")
|
||||
remaining -= len(chunk)
|
||||
|
||||
|
||||
def _bounded_chunks(stream: IO[bytes], size: int) -> Iterator[bytes]:
|
||||
remaining = size
|
||||
while remaining:
|
||||
chunk = stream.read(min(_READ_CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
raise FirmwareCredentialError("firmware apps partition is truncated")
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
|
||||
def _credential_from_chunks(chunks: Iterator[bytes]) -> SecretBuffer:
|
||||
overlap = b""
|
||||
processed = 0
|
||||
matches: dict[int, bytearray] = {}
|
||||
for chunk in chunks:
|
||||
combined = overlap + chunk
|
||||
combined_offset = processed - len(overlap)
|
||||
start = 0
|
||||
while True:
|
||||
index = combined.find(_AP_PSK_DECLARATION, start)
|
||||
if index < 0:
|
||||
break
|
||||
value_start = index + len(_AP_PSK_DECLARATION)
|
||||
value_end = value_start
|
||||
while value_end < len(combined) and combined[value_end] not in b"\x00\r\n":
|
||||
value_end += 1
|
||||
if value_end < len(combined):
|
||||
absolute_offset = combined_offset + index
|
||||
matches[absolute_offset] = bytearray(combined[value_start:value_end].strip())
|
||||
start = index + 1
|
||||
processed += len(chunk)
|
||||
overlap = combined[-_SCAN_OVERLAP:]
|
||||
|
||||
if len(matches) != 1:
|
||||
for value in matches.values():
|
||||
value[:] = b"\x00" * len(value)
|
||||
raise FirmwareCredentialError(
|
||||
"reviewed AP credential declaration was not unique in the apps partition"
|
||||
)
|
||||
value = next(iter(matches.values()))
|
||||
if not 8 <= len(value) <= 63 or any(byte <= 0x20 or byte >= 0x7F for byte in value):
|
||||
value[:] = b"\x00" * len(value)
|
||||
raise FirmwareCredentialError("reviewed AP credential has an invalid WPA-PSK shape")
|
||||
return SecretBuffer(value)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _official_rk_image(archive_path: Path) -> Iterator[IO[bytes]]:
|
||||
try:
|
||||
with tarfile.open(archive_path, mode="r:*") as outer:
|
||||
outer_member = outer.getmember(K1_FW302_OUTER_MEMBER)
|
||||
upgrade_stream = outer.extractfile(outer_member)
|
||||
if upgrade_stream is None:
|
||||
raise FirmwareCredentialError("official upgrade member has no readable payload")
|
||||
with upgrade_stream, tarfile.open(
|
||||
fileobj=upgrade_stream, mode="r|gz"
|
||||
) as upgrade:
|
||||
for member in upgrade:
|
||||
if member.name != K1_FW302_RK_IMAGE_MEMBER:
|
||||
continue
|
||||
image_stream = upgrade.extractfile(member)
|
||||
if image_stream is None:
|
||||
raise FirmwareCredentialError(
|
||||
"official Rockchip image has no readable payload"
|
||||
)
|
||||
with image_stream:
|
||||
yield image_stream
|
||||
return
|
||||
except (KeyError, OSError, tarfile.TarError) as exc:
|
||||
raise FirmwareCredentialError("official firmware archive is unreadable") from exc
|
||||
raise FirmwareCredentialError("reviewed Rockchip image is absent from the archive")
|
||||
|
||||
|
||||
def extract_k1_fw302_ap_credential(archive_path: Path) -> tuple[SecretBuffer, str]:
|
||||
"""Resolve the FW 3.0.2 AP material without printing or persisting it.
|
||||
|
||||
The exact official archive is authenticated first. Only then is the reviewed
|
||||
apps-partition range scanned for one bounded NetworkManager declaration.
|
||||
"""
|
||||
|
||||
resolved_path = archive_path.resolve(strict=True)
|
||||
firmware_sha256 = _sha256(resolved_path)
|
||||
if not hmac.compare_digest(firmware_sha256, K1_FW302_OFFICIAL_ARCHIVE_SHA256):
|
||||
raise FirmwareCredentialError("firmware SHA-256 does not match the reviewed 3.0.2 image")
|
||||
with _official_rk_image(resolved_path) as image_stream:
|
||||
_discard_exact(image_stream, K1_FW302_APPS_OFFSET)
|
||||
secret = _credential_from_chunks(
|
||||
_bounded_chunks(image_stream, K1_FW302_APPS_SIZE)
|
||||
)
|
||||
return secret, firmware_sha256
|
||||
|
||||
|
||||
def import_k1_fw302_ap_credential(
|
||||
archive_path: Path,
|
||||
helper_path: Path,
|
||||
) -> FirmwareCredentialImportResult:
|
||||
"""Install one firmware-scoped material in the host's secure store."""
|
||||
|
||||
secret, firmware_sha256 = extract_k1_fw302_ap_credential(archive_path)
|
||||
try:
|
||||
stored: HostWifiCredentialMaterialStoreResult = store_wifi_credential_material(
|
||||
helper_path,
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
secret.reveal_ascii(),
|
||||
)
|
||||
finally:
|
||||
secret.zeroize()
|
||||
return FirmwareCredentialImportResult(
|
||||
provider_id=K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
firmware_sha256=firmware_sha256,
|
||||
host_adapter=stored["adapter"],
|
||||
outcome=stored["outcome"],
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Authenticated localhost transport for the K1 shadow perception worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.compute.live_perception import LivePerceptionIngress
|
||||
|
||||
TOKEN_BYTES = 32
|
||||
TOKEN_FILE_NAME = "shadow-worker.token"
|
||||
|
||||
|
||||
def ensure_live_shadow_token(repository_root: Path) -> tuple[Path, str]:
|
||||
"""Load or create the private bearer used only through the SSH tunnel."""
|
||||
|
||||
token_root = repository_root.resolve() / ".runtime" / "live-perception"
|
||||
token_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
token_root.chmod(0o700)
|
||||
token_path = token_root / TOKEN_FILE_NAME
|
||||
try:
|
||||
token = token_path.read_text(encoding="ascii").strip()
|
||||
except FileNotFoundError:
|
||||
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
descriptor = os.open(
|
||||
token_path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, f"{token}\n".encode("ascii"))
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(token) < 40 or len(token) > 128 or not token.isascii():
|
||||
raise RuntimeError("live perception shadow token is invalid")
|
||||
with suppress(OSError):
|
||||
token_path.chmod(0o600)
|
||||
return token_path, token
|
||||
|
||||
|
||||
def build_live_perception_shadow_router(
|
||||
ingress: LivePerceptionIngress,
|
||||
plugin_id: str,
|
||||
*,
|
||||
bearer_token: str,
|
||||
result_receiver: Callable[[bytes], bool] | None = None,
|
||||
) -> APIRouter:
|
||||
"""Expose one exclusive sensor stream with bounded diagnostic results back."""
|
||||
|
||||
router = APIRouter(include_in_schema=False)
|
||||
|
||||
@router.websocket(
|
||||
f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow"
|
||||
)
|
||||
async def live_perception_shadow(websocket: WebSocket) -> None:
|
||||
authorization = websocket.headers.get("authorization", "")
|
||||
supplied = authorization.removeprefix("Bearer ")
|
||||
if not supplied or not hmac.compare_digest(supplied, bearer_token):
|
||||
await websocket.close(code=1008, reason="Shadow worker authentication failed")
|
||||
return
|
||||
|
||||
consumer_id = f"shadow-worker-{uuid4().hex}"
|
||||
try:
|
||||
ingress.open_consumer(consumer_id)
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1008, reason="Shadow worker lease is unavailable")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
client_event = asyncio.create_task(websocket.receive())
|
||||
try:
|
||||
while True:
|
||||
ingress_event = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
ingress.take_next,
|
||||
consumer_id,
|
||||
timeout=0.5,
|
||||
)
|
||||
)
|
||||
completed, _ = await asyncio.wait(
|
||||
{client_event, ingress_event},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if client_event in completed:
|
||||
message = client_event.result()
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
break
|
||||
result = message.get("bytes")
|
||||
if not isinstance(result, bytes) or result_receiver is None:
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
await websocket.close(
|
||||
code=1008,
|
||||
reason="Shadow result direction is unavailable",
|
||||
)
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(result_receiver, result)
|
||||
except (RuntimeError, ValueError):
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
await websocket.close(
|
||||
code=1008,
|
||||
reason="Shadow result contract is invalid",
|
||||
)
|
||||
return
|
||||
client_event = asyncio.create_task(websocket.receive())
|
||||
if ingress_event not in completed:
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
continue
|
||||
event = await ingress_event
|
||||
if event is None:
|
||||
if ingress.snapshot()["closed"]:
|
||||
break
|
||||
continue
|
||||
await websocket.send_bytes(event.wire_bytes())
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
finally:
|
||||
client_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await client_event
|
||||
ingress.close_consumer(consumer_id)
|
||||
with suppress(RuntimeError):
|
||||
await websocket.close()
|
||||
|
||||
return router
|
||||
@@ -1,99 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class HostWifiAssociationResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
outcome: str
|
||||
already_associated: bool
|
||||
|
||||
|
||||
class HostWifiAssociationError(RuntimeError):
|
||||
"""One bounded host-side Wi-Fi association attempt failed."""
|
||||
|
||||
def __init__(self, reason_code: str) -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(f"macOS Wi-Fi association failed: {reason_code}")
|
||||
|
||||
|
||||
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||
|
||||
|
||||
def associate_with_wifi_once(
|
||||
helper_path: Path,
|
||||
ssid: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_seconds: float = 45.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiAssociationResult:
|
||||
"""Associate the Mac with one operator-selected Wi-Fi network exactly once.
|
||||
|
||||
The credential is sent to the short-lived CoreWLAN helper through stdin. It
|
||||
never appears in argv, the environment, stdout, stderr, or a persisted
|
||||
artifact. The helper performs at most one scan and one association call.
|
||||
"""
|
||||
|
||||
if sys.platform != "darwin":
|
||||
raise HostWifiAssociationError("unsupported-platform")
|
||||
if not helper_path.is_file():
|
||||
raise HostWifiAssociationError("corewlan-helper-missing")
|
||||
if not 1 <= len(ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(password.encode("utf-8")) <= 64:
|
||||
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
request_bytes = bytearray(
|
||||
json.dumps(
|
||||
{"ssid": ssid, "password": password},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
)
|
||||
try:
|
||||
completed = runner(
|
||||
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise HostWifiAssociationError("corewlan-helper-unavailable") from exc
|
||||
finally:
|
||||
request_bytes[:] = b"\x00" * len(request_bytes)
|
||||
|
||||
if len(completed.stdout) > 4096:
|
||||
raise HostWifiAssociationError("corewlan-response-too-large")
|
||||
try:
|
||||
response: Any = json.loads(completed.stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HostWifiAssociationError("corewlan-response-invalid") from exc
|
||||
if not isinstance(response, dict):
|
||||
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||
|
||||
reason_code = response.get("reason_code")
|
||||
if completed.returncode != 0 or response.get("ok") is not True:
|
||||
if not isinstance(reason_code, str) or not reason_code:
|
||||
reason_code = "corewlan-association-failed"
|
||||
raise HostWifiAssociationError(reason_code)
|
||||
|
||||
already_associated = response.get("already_associated")
|
||||
if not isinstance(already_associated, bool):
|
||||
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": "CoreWLAN",
|
||||
"outcome": "already-associated" if already_associated else "associated",
|
||||
"already_associated": already_associated,
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class CaptureSummary(TypedDict):
|
||||
reconnect_enabled: bool
|
||||
publishing_enabled: bool
|
||||
subscriptions: list[str]
|
||||
requested_duration_seconds: float
|
||||
requested_duration_seconds: float | None
|
||||
capture_elapsed_seconds: float
|
||||
session_elapsed_seconds: float
|
||||
operation_elapsed_seconds: float
|
||||
@@ -753,7 +753,7 @@ def capture_mqtt(
|
||||
out_dir: Path,
|
||||
*,
|
||||
port: int = 1883,
|
||||
duration_seconds: float = 60.0,
|
||||
duration_seconds: float | None = 60.0,
|
||||
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
|
||||
on_clock_established: Callable[[], None] | None = None,
|
||||
on_ready: Callable[[], None] | None = None,
|
||||
@@ -765,7 +765,9 @@ def capture_mqtt(
|
||||
target_ipv4 = validate_private_ipv4(host)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be between 1 and 65535")
|
||||
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||
if duration_seconds is not None and (
|
||||
not math.isfinite(duration_seconds) or duration_seconds <= 0
|
||||
):
|
||||
raise ValueError("duration_seconds must be finite and greater than zero")
|
||||
if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||
raise ValueError(
|
||||
@@ -898,7 +900,11 @@ def capture_mqtt(
|
||||
capture_started = now
|
||||
if on_ready is not None:
|
||||
on_ready()
|
||||
if capture_started is not None and now - capture_started >= duration_seconds:
|
||||
if (
|
||||
capture_started is not None
|
||||
and duration_seconds is not None
|
||||
and now - capture_started >= duration_seconds
|
||||
):
|
||||
state.stop_reason = "duration_elapsed"
|
||||
break
|
||||
if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
|
||||
@@ -970,7 +976,7 @@ def _build_summary(
|
||||
writer: _CaptureWriter,
|
||||
target_ipv4: str,
|
||||
port: int,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
capture_elapsed: float,
|
||||
operation_elapsed: float,
|
||||
max_message_bytes: int,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
ApplicationRequestHeader,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
CALIBRATION_FILE_REQUEST_TOPIC = "lixel/calibration/request/file"
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC = "lixel/calibration/response/file"
|
||||
CALIBRATION_FILE_MESSAGE_TYPE = "CalibFileRequest"
|
||||
CALIBRATION_FILE_READ_COMMAND = 5
|
||||
|
||||
FACTORY_CAMERA_CALIBRATION_PATH = "/mnt/system/factory-data/config/camera.yaml"
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH = (
|
||||
"/mnt/system/factory-data/config/extrinsic_camera_lidar.yaml"
|
||||
)
|
||||
FACTORY_CALIBRATION_PATHS = (
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
)
|
||||
|
||||
MAX_CALIBRATION_FILE_BYTES = 1024 * 1024
|
||||
MAX_CALIBRATION_RESPONSE_BYTES = MAX_CALIBRATION_FILE_BYTES + 16 * 1024
|
||||
MAX_CALIBRATION_PATH_BYTES = 256
|
||||
MAX_CALIBRATION_HEADER_BYTES = 4 * 1024
|
||||
|
||||
|
||||
class CalibrationFileProtocolError(ValueError):
|
||||
"""A K1 calibration-file message violated the read-only contract."""
|
||||
|
||||
|
||||
class CalibrationFileRejected(CalibrationFileProtocolError):
|
||||
"""The K1 rejected a correlated, exact-path calibration-file read."""
|
||||
|
||||
def __init__(self, result_code: int) -> None:
|
||||
self.result_code = result_code
|
||||
super().__init__(f"K1 calibration-file read rejected with result code {result_code}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncodedCalibrationFileRead:
|
||||
path: str
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str = field(repr=False)
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
topic: str = CALIBRATION_FILE_REQUEST_TOPIC
|
||||
response_topic: str = CALIBRATION_FILE_RESPONSE_TOPIC
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
mutates_device: bool = False
|
||||
automatic_retry: bool = False
|
||||
|
||||
def envelope(self, *, ordinal: int) -> OneShotPublishEnvelope:
|
||||
if ordinal not in (1, 2):
|
||||
raise CalibrationFileProtocolError(
|
||||
"factory calibration read ordinal must be one or two"
|
||||
)
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=f"calibration:read:{ordinal}",
|
||||
topic=self.topic,
|
||||
payload=self.payload,
|
||||
payload_sha256=self.payload_sha256,
|
||||
payload_bytes=self.payload_bytes,
|
||||
qos=self.qos,
|
||||
retain=self.retain,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibrationFileContent:
|
||||
path: str
|
||||
content: bytes = field(repr=False)
|
||||
content_sha256: str
|
||||
content_bytes: int
|
||||
result_code: int
|
||||
|
||||
|
||||
def build_factory_calibration_file_read(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
path: str,
|
||||
) -> EncodedCalibrationFileRead:
|
||||
"""Build command 5 for one of the two reviewed factory YAML paths.
|
||||
|
||||
The command is deliberately not a parameter. This module has no API that
|
||||
can encode command 6 (file write), and an arbitrary path cannot cross this
|
||||
boundary.
|
||||
"""
|
||||
|
||||
_require_reviewed_binding(binding)
|
||||
_require_exact_factory_path(path)
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=CALIBRATION_FILE_MESSAGE_TYPE,
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
)
|
||||
encoded_header = b"".join(
|
||||
(
|
||||
_text_field(4, binding.vendor_device_id),
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, authority.openapi_key),
|
||||
)
|
||||
)
|
||||
if len(encoded_header) > MAX_CALIBRATION_HEADER_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration request header exceeds bound")
|
||||
payload = b"".join(
|
||||
(
|
||||
_bytes_field(1, encoded_header),
|
||||
_varint_field(2, CALIBRATION_FILE_READ_COMMAND),
|
||||
_text_field(3, path),
|
||||
)
|
||||
)
|
||||
if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration request exceeds bound")
|
||||
return EncodedCalibrationFileRead(
|
||||
path=path,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=binding.vendor_device_id,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
)
|
||||
|
||||
|
||||
def decode_factory_calibration_file_response(
|
||||
payload: bytes,
|
||||
request: EncodedCalibrationFileRead,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> CalibrationFileContent:
|
||||
"""Decode and correlate one exact-path command-5 response."""
|
||||
|
||||
_require_reviewed_binding(binding)
|
||||
_require_exact_factory_path(request.path)
|
||||
if request.vendor_device_id != binding.vendor_device_id:
|
||||
raise CalibrationFileProtocolError("calibration request binding changed")
|
||||
if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration response exceeds bound")
|
||||
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"calibration response",
|
||||
selected={1, 2, 3, 4, 15},
|
||||
max_fields=32,
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "response.header"),
|
||||
"calibration response header",
|
||||
selected={4, 5, 6},
|
||||
max_fields=16,
|
||||
)
|
||||
device_id = _required_ascii(header, 4, "response.header.device_id")
|
||||
session_id = _required_ascii(header, 5, "response.header.session_id")
|
||||
openapi_key = _required_ascii(header, 6, "response.header.openapi_key")
|
||||
if not hmac.compare_digest(device_id, binding.vendor_device_id):
|
||||
raise CalibrationFileProtocolError("calibration response device identity mismatch")
|
||||
if not hmac.compare_digest(session_id, request.session_id):
|
||||
raise CalibrationFileProtocolError("calibration response session mismatch")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise CalibrationFileProtocolError("calibration response authority mismatch")
|
||||
|
||||
command = _required_uint(top, 2, "response.cmd")
|
||||
if command != CALIBRATION_FILE_READ_COMMAND:
|
||||
raise CalibrationFileProtocolError("calibration response is not a file-read result")
|
||||
observed_path = _required_utf8(top, 3, "response.file_path", MAX_CALIBRATION_PATH_BYTES)
|
||||
if not hmac.compare_digest(observed_path, request.path):
|
||||
raise CalibrationFileProtocolError("calibration response path mismatch")
|
||||
|
||||
error = _selected_unique_fields(
|
||||
_required_bytes(top, 15, "response.error"),
|
||||
"calibration response error",
|
||||
selected={1, 2},
|
||||
max_fields=8,
|
||||
)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise CalibrationFileRejected(result_code)
|
||||
|
||||
content = _required_bytes(top, 4, "response.file_content")
|
||||
if not content:
|
||||
raise CalibrationFileProtocolError("calibration response file content is empty")
|
||||
if len(content) > MAX_CALIBRATION_FILE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration response file exceeds bound")
|
||||
try:
|
||||
content.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CalibrationFileProtocolError(
|
||||
"calibration response file is not valid UTF-8"
|
||||
) from exc
|
||||
if b"\x00" in content:
|
||||
raise CalibrationFileProtocolError("calibration response file contains NUL")
|
||||
return CalibrationFileContent(
|
||||
path=observed_path,
|
||||
content=content,
|
||||
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||
content_bytes=len(content),
|
||||
result_code=result_code,
|
||||
)
|
||||
|
||||
|
||||
def _require_reviewed_binding(binding: LiveDeviceControlBinding) -> None:
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise CalibrationFileProtocolError(
|
||||
"live DeviceInfo does not match the reviewed activated K1 FW 3.0.2 profile"
|
||||
)
|
||||
|
||||
|
||||
def _require_exact_factory_path(path: object) -> None:
|
||||
if not isinstance(path, str) or path not in FACTORY_CALIBRATION_PATHS:
|
||||
raise CalibrationFileProtocolError(
|
||||
"calibration file path is outside the exact two-file allowlist"
|
||||
)
|
||||
|
||||
|
||||
def _selected_unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
selected: set[int],
|
||||
max_fields: int,
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number not in selected:
|
||||
continue
|
||||
if item.number in result:
|
||||
raise CalibrationFileProtocolError(
|
||||
f"{name} field {item.number} is duplicated"
|
||||
)
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise CalibrationFileProtocolError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 0 or not isinstance(item.value, int):
|
||||
raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_utf8(
|
||||
fields: dict[int, ProtoField],
|
||||
number: int,
|
||||
name: str,
|
||||
maximum_bytes: int,
|
||||
) -> str:
|
||||
raw = _required_bytes(fields, number, name)
|
||||
if not raw or len(raw) > maximum_bytes:
|
||||
raise CalibrationFileProtocolError(f"{name} is empty or exceeds bound")
|
||||
try:
|
||||
return raw.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CalibrationFileProtocolError(f"{name} is not valid UTF-8") from exc
|
||||
|
||||
|
||||
def _required_ascii(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
value = _required_utf8(fields, number, name, MAX_CALIBRATION_HEADER_BYTES)
|
||||
try:
|
||||
encoded = value.encode("ascii", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise CalibrationFileProtocolError(f"{name} is not printable ASCII") from exc
|
||||
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise CalibrationFileProtocolError(f"{name} is not printable ASCII")
|
||||
return value
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise CalibrationFileProtocolError("negative protobuf varint is unsupported")
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _key(number: int, wire_type: int) -> bytes:
|
||||
if number < 1:
|
||||
raise CalibrationFileProtocolError("protobuf field number must be positive")
|
||||
return _varint((number << 3) | wire_type)
|
||||
|
||||
|
||||
def _varint_field(number: int, value: int) -> bytes:
|
||||
return _key(number, 0) + _varint(value)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
return _key(number, 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8", errors="strict"))
|
||||
@@ -0,0 +1,534 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
ApplicationControlAuthority,
|
||||
ApplicationRequestHeader,
|
||||
EncodedApplicationRequest,
|
||||
LiveDeviceControlBinding,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_file import (
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
FACTORY_CALIBRATION_PATHS,
|
||||
MAX_CALIBRATION_RESPONSE_BYTES,
|
||||
CalibrationFileContent,
|
||||
build_factory_calibration_file_read,
|
||||
decode_factory_calibration_file_response,
|
||||
)
|
||||
|
||||
CALIBRATION_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
CALIBRATION_EXCHANGE_TIMEOUT_SECONDS = 5.0
|
||||
CALIBRATION_KEEPALIVE_SECONDS = 30
|
||||
CALIBRATION_LOOP_INTERVAL_SECONDS = 0.05
|
||||
MAX_DEVICE_INFO_RESPONSE_BYTES = 64 * 1024
|
||||
|
||||
CALIBRATION_READ_SUBSCRIPTIONS: tuple[tuple[str, int], ...] = (
|
||||
(DEVICE_INFO_RESPONSE_TOPIC, 0),
|
||||
(CALIBRATION_FILE_RESPONSE_TOPIC, 2),
|
||||
)
|
||||
|
||||
|
||||
class CalibrationMqttTransportError(RuntimeError):
|
||||
"""The read-only calibration transport failed before a publish."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CalibrationReadOutcomeUnknown(RuntimeError):
|
||||
"""A read was published, but its exact response could not be established."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "read_outcome_unknown") -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FactoryCalibrationReadResult:
|
||||
binding: LiveDeviceControlBinding
|
||||
files: tuple[CalibrationFileContent, CalibrationFileContent]
|
||||
transport: dict[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibrationMqttSnapshot:
|
||||
state: str
|
||||
connect_attempts: int
|
||||
subscribe_attempts: int
|
||||
publish_attempts: int
|
||||
qos2_completions: int
|
||||
correlated_responses: int
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "read-only-factory-calibration",
|
||||
"state": self.state,
|
||||
"connect_attempts": self.connect_attempts,
|
||||
"subscribe_attempts": self.subscribe_attempts,
|
||||
"publish_attempts": self.publish_attempts,
|
||||
"qos2_completions": self.qos2_completions,
|
||||
"correlated_responses": self.correlated_responses,
|
||||
"clean_session": True,
|
||||
"automatic_reconnect": False,
|
||||
"automatic_retry": False,
|
||||
"request_command": 5,
|
||||
"write_command_available": False,
|
||||
}
|
||||
|
||||
|
||||
class ReviewedCalibrationMqttReader:
|
||||
"""One-connection, no-retry reader for two exact factory calibration files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
port: int = 1883,
|
||||
connect_timeout_seconds: float = CALIBRATION_CONNECT_TIMEOUT_SECONDS,
|
||||
exchange_timeout_seconds: float = CALIBRATION_EXCHANGE_TIMEOUT_SECONDS,
|
||||
allow_device_ap: bool = False,
|
||||
client_factory: Callable[[], mqtt.Client] | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._target_ipv4 = validate_private_ipv4(host)
|
||||
if self._target_ipv4 == AP_FALLBACK_IPV4 and not allow_device_ap:
|
||||
raise ValueError("K1 access-point address is not allowed for this connection mode")
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be between 1 and 65535")
|
||||
for name, value in (
|
||||
("connect_timeout_seconds", connect_timeout_seconds),
|
||||
("exchange_timeout_seconds", exchange_timeout_seconds),
|
||||
):
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{name} must be finite and greater than zero")
|
||||
self._port = port
|
||||
self._connect_timeout_seconds = connect_timeout_seconds
|
||||
self._exchange_timeout_seconds = exchange_timeout_seconds
|
||||
self._client_factory = client_factory
|
||||
self._monotonic = monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._client: mqtt.Client | None = None
|
||||
self._state = "new"
|
||||
self._connected = False
|
||||
self._subscribed = False
|
||||
self._closing = False
|
||||
self._subscription_mid: int | None = None
|
||||
self._completed_publish_mids: set[int] = set()
|
||||
self._messages: deque[tuple[str, bytes]] = deque()
|
||||
self._callback_error: str | None = None
|
||||
self._connect_attempts = 0
|
||||
self._subscribe_attempts = 0
|
||||
self._publish_attempts = 0
|
||||
self._qos2_completions = 0
|
||||
self._correlated_responses = 0
|
||||
|
||||
def read_factory_calibration(
|
||||
self,
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> FactoryCalibrationReadResult:
|
||||
self.open()
|
||||
try:
|
||||
discovery = _build_device_info_discovery(authority)
|
||||
device_info_payload = self._exchange_once(
|
||||
OneShotPublishEnvelope.from_bootstrap_request(discovery),
|
||||
expected_response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
)
|
||||
binding = decode_and_bind_device_info_response(device_info_payload, authority).binding
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise CalibrationMqttTransportError(
|
||||
"live DeviceInfo does not attest the reviewed activated K1 FW 3.0.2 profile",
|
||||
reason_code="compatibility_profile_mismatch",
|
||||
)
|
||||
|
||||
files: list[CalibrationFileContent] = []
|
||||
for ordinal, path in enumerate(FACTORY_CALIBRATION_PATHS, start=1):
|
||||
request = build_factory_calibration_file_read(authority, binding, path)
|
||||
response_payload = self._exchange_once(
|
||||
request.envelope(ordinal=ordinal),
|
||||
expected_response_topic=CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
)
|
||||
files.append(
|
||||
decode_factory_calibration_file_response(
|
||||
response_payload,
|
||||
request,
|
||||
authority,
|
||||
binding,
|
||||
)
|
||||
)
|
||||
if len(files) != 2:
|
||||
raise CalibrationMqttTransportError(
|
||||
"factory calibration read did not return exactly two files"
|
||||
)
|
||||
snapshot = self.snapshot().as_dict()
|
||||
return FactoryCalibrationReadResult(
|
||||
binding=binding,
|
||||
files=(files[0], files[1]),
|
||||
transport=snapshot,
|
||||
)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def open(self) -> CalibrationMqttSnapshot:
|
||||
with self._lock:
|
||||
if self._state != "new":
|
||||
raise CalibrationMqttTransportError(
|
||||
"calibration transport can be opened only once",
|
||||
reason_code="transport_already_opened",
|
||||
)
|
||||
self._state = "connecting"
|
||||
self._connect_attempts = 1
|
||||
client = self._new_client()
|
||||
client.connect_timeout = self._connect_timeout_seconds
|
||||
self._install_callbacks(client)
|
||||
self._client = client
|
||||
try:
|
||||
result = client.connect(
|
||||
self._target_ipv4,
|
||||
port=self._port,
|
||||
keepalive=CALIBRATION_KEEPALIVE_SECONDS,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._fail_before_publish("calibration MQTT connect call failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
self._fail_before_publish("calibration MQTT connect call was rejected")
|
||||
deadline = self._monotonic() + self._connect_timeout_seconds
|
||||
self._drive_until(lambda: self._subscribed, deadline, post_publish=False)
|
||||
with self._lock:
|
||||
self._state = "ready"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._state == "closed":
|
||||
return
|
||||
self._closing = True
|
||||
client = self._client
|
||||
if client is not None:
|
||||
try:
|
||||
if self._subscribed:
|
||||
client.unsubscribe([topic for topic, _qos in CALIBRATION_READ_SUBSCRIPTIONS])
|
||||
client.disconnect()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass
|
||||
with self._lock:
|
||||
self._connected = False
|
||||
self._subscribed = False
|
||||
self._messages.clear()
|
||||
if self._state not in {"failed", "poisoned"}:
|
||||
self._state = "closed"
|
||||
|
||||
def snapshot(self) -> CalibrationMqttSnapshot:
|
||||
with self._lock:
|
||||
return CalibrationMqttSnapshot(
|
||||
state=self._state,
|
||||
connect_attempts=self._connect_attempts,
|
||||
subscribe_attempts=self._subscribe_attempts,
|
||||
publish_attempts=self._publish_attempts,
|
||||
qos2_completions=self._qos2_completions,
|
||||
correlated_responses=self._correlated_responses,
|
||||
)
|
||||
|
||||
def _exchange_once(
|
||||
self,
|
||||
envelope: OneShotPublishEnvelope,
|
||||
*,
|
||||
expected_response_topic: str,
|
||||
) -> bytes:
|
||||
if envelope.topic not in {
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
"lixel/calibration/request/file",
|
||||
}:
|
||||
raise ValueError("read-only calibration request topic is not allowlisted")
|
||||
if expected_response_topic not in {
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
}:
|
||||
raise ValueError("read-only calibration response topic is not allowlisted")
|
||||
with self._lock:
|
||||
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||
raise CalibrationMqttTransportError("calibration transport is not ready")
|
||||
if self._messages:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"an uncorrelated response preceded the next calibration read",
|
||||
reason_code="unexpected_response_before_publish",
|
||||
)
|
||||
self._publish_attempts += 1
|
||||
|
||||
client = self._require_client()
|
||||
try:
|
||||
info = client.publish(
|
||||
envelope.topic,
|
||||
payload=envelope.payload,
|
||||
qos=envelope.qos,
|
||||
retain=envelope.retain,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._fail_after_publish("calibration MQTT publish call failed", exc)
|
||||
if info.rc != mqtt.MQTT_ERR_SUCCESS or info.mid is None:
|
||||
self._fail_after_publish("calibration MQTT publish returned an unsafe result")
|
||||
publish_mid = int(info.mid)
|
||||
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||
|
||||
def complete() -> bool:
|
||||
with self._lock:
|
||||
return publish_mid in self._completed_publish_mids and bool(self._messages)
|
||||
|
||||
self._drive_until(complete, deadline, post_publish=True)
|
||||
self._service_once(post_publish=True)
|
||||
with self._lock:
|
||||
if len(self._messages) != 1:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"duplicate calibration response made read correlation ambiguous",
|
||||
reason_code="duplicate_response",
|
||||
)
|
||||
topic, payload = self._messages.popleft()
|
||||
if topic != expected_response_topic:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"calibration response topic did not match the issued read",
|
||||
reason_code="response_topic_mismatch",
|
||||
)
|
||||
self._correlated_responses += 1
|
||||
return payload
|
||||
|
||||
def _new_client(self) -> mqtt.Client:
|
||||
if self._client_factory is not None:
|
||||
return self._client_factory()
|
||||
return mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"mck1-cal-{secrets.token_hex(7)}",
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
reconnect_on_failure=False,
|
||||
)
|
||||
|
||||
def _install_callbacks(self, client: mqtt.Client) -> None:
|
||||
def on_connect(
|
||||
callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT broker rejected connection")
|
||||
return
|
||||
with self._lock:
|
||||
self._connected = True
|
||||
self._subscribe_attempts = 1
|
||||
try:
|
||||
result, mid = callback_client.subscribe(list(CALIBRATION_READ_SUBSCRIPTIONS))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
self._set_callback_error("calibration MQTT response subscription failed")
|
||||
return
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
|
||||
self._set_callback_error("calibration MQTT response subscription was rejected")
|
||||
return
|
||||
with self._lock:
|
||||
self._subscription_mid = mid
|
||||
|
||||
def on_subscribe(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
mid: int,
|
||||
reason_codes: list[ReasonCode],
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
expected_mid = self._subscription_mid
|
||||
if mid != expected_mid or len(reason_codes) != len(CALIBRATION_READ_SUBSCRIPTIONS):
|
||||
self._set_callback_error("calibration MQTT received an unexpected SUBACK")
|
||||
return
|
||||
if any(reason_code.is_failure for reason_code in reason_codes):
|
||||
self._set_callback_error("calibration MQTT broker rejected a subscription")
|
||||
return
|
||||
with self._lock:
|
||||
self._subscribed = True
|
||||
|
||||
def on_publish(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
mid: int,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT QoS2 transaction failed")
|
||||
return
|
||||
with self._lock:
|
||||
self._completed_publish_mids.add(mid)
|
||||
self._qos2_completions += 1
|
||||
|
||||
def on_message(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
message: mqtt.MQTTMessage,
|
||||
) -> None:
|
||||
allowed = {topic for topic, _qos in CALIBRATION_READ_SUBSCRIPTIONS}
|
||||
if message.topic not in allowed:
|
||||
self._set_callback_error("calibration MQTT received an unreviewed topic")
|
||||
return
|
||||
payload = bytes(message.payload)
|
||||
maximum = (
|
||||
MAX_DEVICE_INFO_RESPONSE_BYTES
|
||||
if message.topic == DEVICE_INFO_RESPONSE_TOPIC
|
||||
else MAX_CALIBRATION_RESPONSE_BYTES
|
||||
)
|
||||
if len(payload) > maximum:
|
||||
self._set_callback_error("calibration MQTT response exceeds bound")
|
||||
return
|
||||
with self._lock:
|
||||
self._messages.append((message.topic, payload))
|
||||
|
||||
def on_disconnect(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.DisconnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
expected = self._closing
|
||||
self._connected = False
|
||||
if not expected or reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT connection ended unexpectedly")
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_subscribe = on_subscribe
|
||||
client.on_publish = on_publish
|
||||
client.on_message = on_message
|
||||
client.on_disconnect = on_disconnect
|
||||
|
||||
def _drive_until(
|
||||
self,
|
||||
predicate: Callable[[], bool],
|
||||
deadline: float,
|
||||
*,
|
||||
post_publish: bool,
|
||||
) -> None:
|
||||
while not predicate():
|
||||
with self._lock:
|
||||
callback_error = self._callback_error
|
||||
if callback_error is not None:
|
||||
if post_publish:
|
||||
self._fail_after_publish(callback_error)
|
||||
self._fail_before_publish(callback_error)
|
||||
if self._monotonic() >= deadline:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT response barrier timed out")
|
||||
self._fail_before_publish("calibration MQTT connection/subscription timed out")
|
||||
self._service_once(post_publish=post_publish)
|
||||
|
||||
def _service_once(self, *, post_publish: bool) -> None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
result = client.loop(timeout=CALIBRATION_LOOP_INTERVAL_SECONDS)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT network loop failed", exc)
|
||||
self._fail_before_publish("calibration MQTT network loop failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT network loop returned an error")
|
||||
self._fail_before_publish("calibration MQTT network loop returned an error")
|
||||
|
||||
def _require_client(self) -> mqtt.Client:
|
||||
if self._client is None:
|
||||
raise CalibrationMqttTransportError("calibration MQTT client is unavailable")
|
||||
return self._client
|
||||
|
||||
def _set_callback_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
if self._callback_error is None:
|
||||
self._callback_error = message
|
||||
|
||||
def _fail_before_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
self._state = "failed"
|
||||
self.close()
|
||||
error = CalibrationMqttTransportError(message)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
||||
def _fail_after_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
self._state = "poisoned"
|
||||
self.close()
|
||||
error = CalibrationReadOutcomeUnknown(message)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
||||
|
||||
def _build_device_info_discovery(
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> EncodedApplicationRequest:
|
||||
header = ApplicationRequestHeader(
|
||||
message_type="DeviceInfoRequest",
|
||||
authority=authority,
|
||||
)
|
||||
encoded_header = b"".join(
|
||||
(
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, authority.openapi_key),
|
||||
)
|
||||
)
|
||||
payload = _bytes_field(1, encoded_header)
|
||||
return EncodedApplicationRequest(
|
||||
ordinal=1,
|
||||
phase="identity-discovery",
|
||||
message_type="DeviceInfoRequest",
|
||||
topic=DEVICE_INFO_REQUEST_TOPIC,
|
||||
response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=False,
|
||||
requires_live_binding=False,
|
||||
response_required=True,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
key = _varint((number << 3) | 2)
|
||||
return key + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8", errors="strict"))
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
|
||||
QUICK_CONNECT_HOST_PROFILE_PREFIX = "xgrids.lixelkity-k1.quick-connect.fw-3.v2"
|
||||
|
||||
|
||||
def quick_connect_host_profile_id(device_ap_ssid: str) -> str:
|
||||
"""Return an opaque, device-scoped host credential profile identifier.
|
||||
|
||||
LixelGO's reviewed DeviceData model carries WiFiAP_SSID and
|
||||
WiFiAP_Password per device. The SSID is already operator-visible, but the
|
||||
credential-store account stays opaque so neither value is mistaken for a
|
||||
universal K1 factory profile.
|
||||
"""
|
||||
|
||||
normalized = unicodedata.normalize("NFC", device_ap_ssid).strip()
|
||||
encoded = normalized.encode("utf-8")
|
||||
if not 1 <= len(encoded) <= 32:
|
||||
raise ValueError("K1 AP SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
suffix = hashlib.sha256(encoded).hexdigest()[:24]
|
||||
return f"{QUICK_CONNECT_HOST_PROFILE_PREFIX}.{suffix}"
|
||||
@@ -52,6 +52,9 @@ APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD = 100_000
|
||||
RECORDED_VIEW_POINT_STRIDE = 4
|
||||
RECORDED_VIEW_POINT_FRAME_STRIDE = 5
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
@@ -61,8 +64,9 @@ RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
|
||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||
RecordedView = Literal["spatial", "perception", "metrics"]
|
||||
RecordedView = Literal["spatial", "perception", "perception3d", "metrics"]
|
||||
|
||||
|
||||
class RrdExportSummary(TypedDict):
|
||||
@@ -191,13 +195,15 @@ def export_k1mqtt_to_rrd(
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> RrdExportSummary:
|
||||
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||
"""Project a bounded-rate view of K1 data into one operator RRD.
|
||||
|
||||
The raw capture remains the source of record. The derived RRD uses a
|
||||
recording-local duration timeline whose zero is the durable capture-clock
|
||||
origin for v2 recordings (or the first raw message for legacy captures).
|
||||
It never traverses the bounded live-preview queue, so export throughput
|
||||
cannot drop point or pose frames.
|
||||
It never traverses the bounded live-preview queue. Point-cloud frames and
|
||||
very dense point batches are deterministically sampled for interactive
|
||||
rendering while counters, poses, capture boundaries and the native capture
|
||||
remain complete. AI jobs always read the complete native capture.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
@@ -328,9 +334,10 @@ def export_k1mqtt_to_rrd(
|
||||
)
|
||||
counters.observe_decoded(session_time_ns)
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
_log_points(recording, decoded, settings)
|
||||
counters.point_frames += 1
|
||||
counters.points += decoded.point_count
|
||||
if _should_publish_recorded_point_frame(counters.point_frames):
|
||||
_log_points(recording, decoded, settings)
|
||||
elif isinstance(decoded, DecodedPoseView):
|
||||
position = (
|
||||
float(decoded.position_xyz[0]),
|
||||
@@ -548,6 +555,10 @@ def _recorded_blueprint(
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
# Dynamic perception must not inherit the mapping view's
|
||||
# historical accumulation window. It is rendered latest-at in
|
||||
# the dedicated perception view below.
|
||||
"/world/perception": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
# A positive window accumulates historical frames. With no
|
||||
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||
@@ -560,15 +571,41 @@ def _recorded_blueprint(
|
||||
background=[7, 8, 10, 255],
|
||||
)
|
||||
camera_view.id = RECORDED_CAMERA_VIEW_ID
|
||||
perception_3d_view = rrb.Spatial3DView(
|
||||
# Present derived cuboids in the calibrated world scene instead of an
|
||||
# isolated /world/perception subtree. No visible time range is set on
|
||||
# this view: points and cuboids therefore remain latest-at and do not
|
||||
# accumulate into the overlapping-box failure mode.
|
||||
origin="/world",
|
||||
name="Сегментация и объекты · 3D",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
"/world/points": rrb.EntityBehavior(visible=settings.show_points),
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
"/world/perception": rrb.EntityBehavior(visible=True),
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
)
|
||||
perception_3d_view.id = RECORDED_PERCEPTION_3D_VIEW_ID
|
||||
metrics_view = rrb.TimeSeriesView(
|
||||
origin="/metrics/device",
|
||||
name="Маршрут и время",
|
||||
)
|
||||
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
||||
active_tab = {"spatial": 0, "perception": 1, "metrics": 2}[active_view]
|
||||
active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[
|
||||
active_view
|
||||
]
|
||||
root_container = rrb.Tabs(
|
||||
spatial_view,
|
||||
camera_view,
|
||||
perception_3d_view,
|
||||
metrics_view,
|
||||
active_tab=active_tab,
|
||||
)
|
||||
@@ -747,6 +784,7 @@ def _log_points(
|
||||
if frame.colors_rgb is None
|
||||
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
positions, intensities, rgb = _recorded_view_points(positions, intensities, rgb)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D(
|
||||
@@ -757,6 +795,35 @@ def _log_points(
|
||||
)
|
||||
|
||||
|
||||
def _recorded_view_points(
|
||||
positions: np.ndarray,
|
||||
intensities: np.ndarray,
|
||||
rgb: np.ndarray | None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
||||
# The native K1 capture remains the complete source of record and all AI
|
||||
# jobs read that source directly. Rerun is the interactive operator
|
||||
# projection: bound the temporal frame rate, but preserve complete normal
|
||||
# K1 scans. The AI composition intentionally uses one latest point frame
|
||||
# so dynamic cuboids do not stack; thinning a normal ~2.4k-point scan here
|
||||
# made that view visibly bald. Keep spatial decimation only as an emergency
|
||||
# guard for unusually large (>100k point) frames from future hardware.
|
||||
if len(positions) <= RECORDED_VIEW_POINT_DECIMATION_THRESHOLD:
|
||||
return positions, intensities, rgb
|
||||
return (
|
||||
positions[::RECORDED_VIEW_POINT_STRIDE],
|
||||
intensities[::RECORDED_VIEW_POINT_STRIDE],
|
||||
None if rgb is None else rgb[::RECORDED_VIEW_POINT_STRIDE],
|
||||
)
|
||||
|
||||
|
||||
def _should_publish_recorded_point_frame(frame_number: int) -> bool:
|
||||
"""Keep the first point frame and then a stable 2 Hz operator cadence."""
|
||||
|
||||
if frame_number < 1:
|
||||
raise ValueError("point frame number must be positive")
|
||||
return frame_number == 1 or (frame_number - 1) % RECORDED_VIEW_POINT_FRAME_STRIDE == 0
|
||||
|
||||
|
||||
def _log_pose(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPoseView,
|
||||
|
||||
@@ -11,6 +11,10 @@ from pathlib import Path
|
||||
from typing import Literal, Protocol, TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.compute.live_perception import (
|
||||
LivePerceptionResultFrame,
|
||||
decode_live_perception_result,
|
||||
)
|
||||
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
@@ -34,6 +38,7 @@ BridgeFactory = Callable[..., RerunBridge]
|
||||
# bounded pose queue. The compact queue protects acquisition from a slow
|
||||
# visualizer, but under sustained pressure it can still evict pose messages.
|
||||
PREVIEW_QUEUE_SIZE = 4
|
||||
PERCEPTION_PREVIEW_QUEUE_SIZE = 2
|
||||
|
||||
|
||||
class CanonicalNormalizer(Protocol):
|
||||
@@ -92,6 +97,9 @@ class VisualizationRuntime:
|
||||
self._closed = False
|
||||
self._scene_settings = RerunSceneSettings()
|
||||
self._metrics = BridgeMetrics()
|
||||
self._perception_messages: queue.Queue[LivePerceptionResultFrame] = queue.Queue(
|
||||
maxsize=PERCEPTION_PREVIEW_QUEUE_SIZE
|
||||
)
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
with self._lock:
|
||||
@@ -113,6 +121,29 @@ class VisualizationRuntime:
|
||||
self._notify()
|
||||
return self.snapshot()
|
||||
|
||||
def publish_perception_result(self, encoded: bytes) -> bool:
|
||||
"""Admit one validated latest-wins AI result without blocking acquisition."""
|
||||
|
||||
frame = decode_live_perception_result(encoded)
|
||||
with self._lock:
|
||||
active = self._source_mode in {"live", "replay"} and not self._closed
|
||||
if not active:
|
||||
return False
|
||||
try:
|
||||
self._perception_messages.put_nowait(frame)
|
||||
return True
|
||||
except queue.Full:
|
||||
pass
|
||||
with suppress(queue.Empty):
|
||||
self._perception_messages.get_nowait()
|
||||
self._perception_messages.task_done()
|
||||
self._metrics.perception_dropped()
|
||||
try:
|
||||
self._perception_messages.put_nowait(frame)
|
||||
return True
|
||||
except queue.Full:
|
||||
return False
|
||||
|
||||
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
@@ -140,10 +171,12 @@ class VisualizationRuntime:
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
duration_seconds: float = 3600.0,
|
||||
duration_seconds: float | None = None,
|
||||
project_name: str,
|
||||
) -> None:
|
||||
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||
if duration_seconds is not None and (
|
||||
not math.isfinite(duration_seconds) or duration_seconds <= 0
|
||||
):
|
||||
raise ValueError("длительность приёма должна быть больше нуля")
|
||||
clock_established = threading.Event()
|
||||
self._start(
|
||||
@@ -311,7 +344,7 @@ class VisualizationRuntime:
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
project_name: str,
|
||||
clock_established: threading.Event,
|
||||
) -> None:
|
||||
@@ -360,6 +393,12 @@ class VisualizationRuntime:
|
||||
publisher_ready = threading.Event()
|
||||
publisher_aborted = threading.Event()
|
||||
publisher_error: list[BaseException] = []
|
||||
while True:
|
||||
try:
|
||||
self._perception_messages.get_nowait()
|
||||
self._perception_messages.task_done()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def enqueue(message: StreamMessage) -> None:
|
||||
# A plugin may consume non-visual status before the bounded preview
|
||||
@@ -426,11 +465,30 @@ class VisualizationRuntime:
|
||||
self._notify()
|
||||
if publisher_aborted.is_set():
|
||||
return
|
||||
while not source_done.is_set() or not messages.empty():
|
||||
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
|
||||
while (
|
||||
not source_done.is_set()
|
||||
or not messages.empty()
|
||||
or not self._perception_messages.empty()
|
||||
):
|
||||
if (
|
||||
self._stop_event.is_set()
|
||||
and source_done.is_set()
|
||||
and messages.empty()
|
||||
and self._perception_messages.empty()
|
||||
):
|
||||
break
|
||||
try:
|
||||
message = messages.get(timeout=0.1)
|
||||
perception = self._perception_messages.get_nowait()
|
||||
except queue.Empty:
|
||||
perception = None
|
||||
if perception is not None:
|
||||
try:
|
||||
bridge.process_perception(perception)
|
||||
finally:
|
||||
self._perception_messages.task_done()
|
||||
continue
|
||||
try:
|
||||
message = messages.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
@@ -573,7 +631,7 @@ def new_live_session_dir(sessions_root: Path) -> Path:
|
||||
def _write_live_session_preamble(
|
||||
out_dir: Path,
|
||||
host: str,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
project_name: str,
|
||||
) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Host-owned network adapters used by device plugins."""
|
||||
|
||||
from k1link.host_network.wifi import (
|
||||
HostWifiCredentialMaterialAvailabilityResult,
|
||||
HostWifiCredentialMaterialStoreResult,
|
||||
HostWifiProfileError,
|
||||
associate_with_wifi_profile_once,
|
||||
check_wifi_credential_material,
|
||||
check_wifi_profile,
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
store_wifi_credential_material,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"HostWifiCredentialMaterialAvailabilityResult",
|
||||
"HostWifiCredentialMaterialStoreResult",
|
||||
"HostWifiProfileError",
|
||||
"associate_with_wifi_profile_once",
|
||||
"check_wifi_credential_material",
|
||||
"check_wifi_profile",
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
"store_wifi_credential_material",
|
||||
]
|
||||
@@ -0,0 +1,429 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
|
||||
|
||||
class HostWifiProfileAssociationResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
outcome: str
|
||||
already_associated: bool
|
||||
profile_enrolled: bool
|
||||
scan_attempt_count: int
|
||||
scan_elapsed_ms: int
|
||||
credential_source: str
|
||||
|
||||
|
||||
class HostWifiProfileStoreResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
outcome: Literal["stored"]
|
||||
|
||||
|
||||
class HostWifiProfileAvailabilityResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
available: bool
|
||||
|
||||
|
||||
class HostWifiProfileEnsureResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
available: bool
|
||||
profile_enrolled: bool
|
||||
credential_source: str | None
|
||||
|
||||
|
||||
class HostWifiCredentialMaterialStoreResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
outcome: Literal["stored"]
|
||||
|
||||
|
||||
class HostWifiCredentialMaterialAvailabilityResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
available: bool
|
||||
|
||||
|
||||
_ERROR_MESSAGES = {
|
||||
"unsupported-platform": (
|
||||
"для этой ОС ещё не установлен адаптер системных Wi-Fi-профилей"
|
||||
),
|
||||
"profile-unavailable": (
|
||||
"реквизиты выбранного K1 не заведены на этом управляющем устройстве"
|
||||
),
|
||||
"credential-source-unavailable": (
|
||||
"credential provider точной версии прошивки не установлен на этом устройстве"
|
||||
),
|
||||
"network-not-found": (
|
||||
"точка доступа выбранного K1 не найдена; проверьте режим K1 и питание"
|
||||
),
|
||||
"profile-ssid-mismatch": "сохранённый профиль принадлежит другой точке доступа",
|
||||
"credential-entry-cancelled": "ввод пароля точки доступа K1 отменён оператором",
|
||||
"credential-invalid": "пароль точки доступа K1 имеет недопустимую длину",
|
||||
"host-wifi-operation-timeout": (
|
||||
"оператор не завершил системное подключение Wi-Fi за отведённое время"
|
||||
),
|
||||
"wifi-interface-unavailable": "системный Wi-Fi-интерфейс недоступен",
|
||||
"corewlan-error": "системный Wi-Fi не смог подключиться к точке доступа K1",
|
||||
}
|
||||
|
||||
|
||||
class HostWifiProfileError(RuntimeError):
|
||||
"""One bounded host-side Wi-Fi profile operation failed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reason_code: str,
|
||||
*,
|
||||
scan_attempt_count: int | None = None,
|
||||
scan_elapsed_ms: int | None = None,
|
||||
) -> None:
|
||||
self.reason_code = reason_code
|
||||
self.scan_attempt_count = scan_attempt_count
|
||||
self.scan_elapsed_ms = scan_elapsed_ms
|
||||
message = _ERROR_MESSAGES.get(reason_code, "операция системного Wi-Fi завершилась ошибкой")
|
||||
super().__init__(f"{message} ({reason_code})")
|
||||
|
||||
|
||||
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||
|
||||
|
||||
def _validate_profile_id(profile_id: str) -> None:
|
||||
if not 1 <= len(profile_id) <= 128:
|
||||
raise ValueError("host Wi-Fi profile id must contain between 1 and 128 characters")
|
||||
if not all(
|
||||
character.isascii() and (character.isalnum() or character in "._-")
|
||||
for character in profile_id
|
||||
):
|
||||
raise ValueError("host Wi-Fi profile id contains unsupported characters")
|
||||
|
||||
|
||||
def _run_macos_helper(
|
||||
helper_path: Path,
|
||||
request: dict[str, object],
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
runner: RunProcess,
|
||||
) -> dict[str, Any]:
|
||||
if sys.platform != "darwin":
|
||||
raise HostWifiProfileError("unsupported-platform")
|
||||
if not helper_path.is_file():
|
||||
raise HostWifiProfileError("host-wifi-helper-missing")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
request_bytes = bytearray(
|
||||
json.dumps(request, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
try:
|
||||
completed = runner(
|
||||
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise HostWifiProfileError("host-wifi-operation-timeout") from exc
|
||||
except OSError as exc:
|
||||
raise HostWifiProfileError("host-wifi-helper-unavailable") from exc
|
||||
finally:
|
||||
request_bytes[:] = b"\x00" * len(request_bytes)
|
||||
|
||||
if len(completed.stdout) > 4096:
|
||||
raise HostWifiProfileError("host-wifi-response-too-large")
|
||||
try:
|
||||
response: Any = json.loads(completed.stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HostWifiProfileError("host-wifi-response-invalid") from exc
|
||||
if not isinstance(response, dict):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
|
||||
reason_code = response.get("reason_code")
|
||||
if completed.returncode != 0 or response.get("ok") is not True:
|
||||
if not isinstance(reason_code, str) or not reason_code:
|
||||
reason_code = "host-wifi-operation-failed"
|
||||
scan_attempt_count = response.get("scan_attempt_count")
|
||||
scan_elapsed_ms = response.get("scan_elapsed_ms")
|
||||
raise HostWifiProfileError(
|
||||
reason_code,
|
||||
scan_attempt_count=(
|
||||
scan_attempt_count
|
||||
if type(scan_attempt_count) is int and scan_attempt_count >= 1
|
||||
else None
|
||||
),
|
||||
scan_elapsed_ms=(
|
||||
scan_elapsed_ms
|
||||
if type(scan_elapsed_ms) is int and scan_elapsed_ms >= 0
|
||||
else None
|
||||
),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def associate_with_wifi_profile_once(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
expected_ssid: str,
|
||||
*,
|
||||
scan_timeout_seconds: float = 15.0,
|
||||
timeout_seconds: float = 180.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiProfileAssociationResult:
|
||||
"""Associate through one device-scoped OS profile without exposing its secret.
|
||||
|
||||
The selected device supplies the expected, operator-visible AP SSID. The
|
||||
platform helper performs bounded exact-SSID discovery followed by at most
|
||||
one association. It resolves a device-scoped secret from the OS credential
|
||||
stores, or asks for it through a native secure prompt on first use. The
|
||||
secret never crosses the helper boundary.
|
||||
"""
|
||||
|
||||
_validate_profile_id(profile_id)
|
||||
if not 1 <= len(expected_ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 0 <= scan_timeout_seconds <= 60:
|
||||
raise ValueError("scan_timeout_seconds must be between 0 and 60")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "associate",
|
||||
"profile_id": profile_id,
|
||||
"ssid": expected_ssid,
|
||||
"scan_timeout_seconds": scan_timeout_seconds,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
already_associated = response.get("already_associated")
|
||||
profile_enrolled = response.get("profile_enrolled")
|
||||
scan_attempt_count = response.get("scan_attempt_count")
|
||||
scan_elapsed_ms = response.get("scan_elapsed_ms")
|
||||
credential_source = response.get("credential_source")
|
||||
adapter = response.get("adapter")
|
||||
if (
|
||||
not isinstance(already_associated, bool)
|
||||
or not isinstance(profile_enrolled, bool)
|
||||
or not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or type(scan_attempt_count) is not int
|
||||
or scan_attempt_count < 1
|
||||
or type(scan_elapsed_ms) is not int
|
||||
or scan_elapsed_ms < 0
|
||||
or not isinstance(credential_source, str)
|
||||
or credential_source not in {
|
||||
"mission-core-keychain",
|
||||
"system-wifi-keychain",
|
||||
"native-secure-prompt",
|
||||
"exact-firmware-profile",
|
||||
}
|
||||
):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"outcome": "already-associated" if already_associated else "associated",
|
||||
"already_associated": already_associated,
|
||||
"profile_enrolled": profile_enrolled,
|
||||
"scan_attempt_count": scan_attempt_count,
|
||||
"scan_elapsed_ms": scan_elapsed_ms,
|
||||
"credential_source": credential_source,
|
||||
}
|
||||
|
||||
|
||||
def check_wifi_profile(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
expected_ssid: str,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiProfileAvailabilityResult:
|
||||
"""Check one device-scoped Keychain profile without scanning or joining Wi-Fi."""
|
||||
|
||||
_validate_profile_id(profile_id)
|
||||
if not 1 <= len(expected_ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "check-profile",
|
||||
"profile_id": profile_id,
|
||||
"ssid": expected_ssid,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
available = response.get("profile_available")
|
||||
adapter = response.get("adapter")
|
||||
if not isinstance(available, bool) or not isinstance(adapter, str) or not adapter:
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"available": available,
|
||||
}
|
||||
|
||||
|
||||
def ensure_wifi_profile_from_credential_source(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
expected_ssid: str,
|
||||
credential_source_id: str,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiProfileEnsureResult:
|
||||
"""Materialize one device profile from a firmware-scoped secure-store item.
|
||||
|
||||
Only opaque identifiers and the operator-visible SSID cross the platform
|
||||
helper boundary. The credential stays inside the OS credential store.
|
||||
"""
|
||||
|
||||
_validate_profile_id(profile_id)
|
||||
_validate_profile_id(credential_source_id)
|
||||
if not 1 <= len(expected_ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "ensure-profile",
|
||||
"profile_id": profile_id,
|
||||
"ssid": expected_ssid,
|
||||
"credential_source_id": credential_source_id,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
available = response.get("profile_available")
|
||||
profile_enrolled = response.get("profile_enrolled")
|
||||
credential_source = response.get("credential_source")
|
||||
adapter = response.get("adapter")
|
||||
if (
|
||||
not isinstance(available, bool)
|
||||
or not isinstance(profile_enrolled, bool)
|
||||
or not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or (credential_source is not None and not isinstance(credential_source, str))
|
||||
):
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"available": available,
|
||||
"profile_enrolled": profile_enrolled,
|
||||
"credential_source": credential_source,
|
||||
}
|
||||
|
||||
|
||||
def store_wifi_credential_material(
|
||||
helper_path: Path,
|
||||
credential_source_id: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiCredentialMaterialStoreResult:
|
||||
"""Store firmware-scoped material without putting it in argv or output."""
|
||||
|
||||
_validate_profile_id(credential_source_id)
|
||||
if not 8 <= len(password.encode("utf-8")) <= 63:
|
||||
raise ValueError("WPA-PSK must contain between 8 and 63 UTF-8 bytes")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "store-credential-material",
|
||||
"profile_id": credential_source_id,
|
||||
"password": password,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
adapter = response.get("adapter")
|
||||
if response.get("stored") is not True or not isinstance(adapter, str) or not adapter:
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"outcome": "stored",
|
||||
}
|
||||
|
||||
|
||||
def check_wifi_credential_material(
|
||||
helper_path: Path,
|
||||
credential_source_id: str,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiCredentialMaterialAvailabilityResult:
|
||||
"""Check one opaque firmware provider item without loading its secret."""
|
||||
|
||||
_validate_profile_id(credential_source_id)
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "check-credential-material",
|
||||
"profile_id": credential_source_id,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
available = response.get("profile_available")
|
||||
adapter = response.get("adapter")
|
||||
if not isinstance(available, bool) or not isinstance(adapter, str) or not adapter:
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"available": available,
|
||||
}
|
||||
|
||||
|
||||
def store_wifi_profile_once(
|
||||
helper_path: Path,
|
||||
profile_id: str,
|
||||
ssid: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiProfileStoreResult:
|
||||
"""Store one profile in the current user's secure OS credential store.
|
||||
|
||||
This is an explicit local administration boundary. The secret is carried
|
||||
only in the helper's stdin and is never placed in argv, the environment,
|
||||
stdout, stderr, an evidence manifest, or source control.
|
||||
"""
|
||||
|
||||
_validate_profile_id(profile_id)
|
||||
if not 1 <= len(ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(password.encode("utf-8")) <= 64:
|
||||
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
|
||||
response = _run_macos_helper(
|
||||
helper_path,
|
||||
{
|
||||
"action": "store-profile",
|
||||
"profile_id": profile_id,
|
||||
"ssid": ssid,
|
||||
"password": password,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
runner=runner,
|
||||
)
|
||||
adapter = response.get("adapter")
|
||||
if response.get("stored") is not True or not isinstance(adapter, str) or not adapter:
|
||||
raise HostWifiProfileError("host-wifi-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": adapter,
|
||||
"outcome": "stored",
|
||||
}
|
||||
@@ -20,9 +20,9 @@ CAMERA_ARCHIVE_SCHEMA = "missioncore.camera-recording/v1"
|
||||
CAMERA_INDEX_SCHEMA = "missioncore.camera-recording-index/v1"
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA = "missioncore.observation-recorded-media/v2"
|
||||
RECORDED_MEDIA_PREPARATION_SCHEMA = "missioncore.recorded-media-preparation/v1"
|
||||
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
|
||||
MAX_MEDIA_SUMMARY_BYTES = 2 * 1024 * 1024
|
||||
MAX_MEDIA_INDEX_LINE_BYTES = 64 * 1024
|
||||
MAX_INIT_BYTES = 8 * 1024 * 1024
|
||||
MAX_MEDIA_SEGMENTS = 500_000
|
||||
MAX_MEDIA_SEGMENT_BYTES = 64 * 1024 * 1024
|
||||
MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
MAX_MP4_BOXES = 100_000
|
||||
@@ -30,7 +30,6 @@ MAX_MP4_SAMPLES_PER_FRAGMENT = 1_000_000
|
||||
MAX_MP4_FRAGMENT_DURATION_SECONDS = 3_600.0
|
||||
MEDIA_RECORDING_TIMELINE_TOLERANCE_SECONDS = 0.05
|
||||
MAX_MEDIA_EPOCHS = 4_096
|
||||
MAX_PREPARED_MEDIA_SIDECAR_BYTES = 256 * 1024 * 1024
|
||||
|
||||
_EPOCH_PATTERN = re.compile(r"^epoch-([1-9][0-9]*)$")
|
||||
_SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
||||
@@ -258,7 +257,8 @@ class RecordedMediaInspector:
|
||||
return None
|
||||
path = root / _sidecar_name(artifact.session_id, artifact.artifact_id)
|
||||
try:
|
||||
payload = _read_confined_file(path, root, MAX_PREPARED_MEDIA_SIDECAR_BYTES)
|
||||
sidecar_stat = _confined_file_stat(path, root)
|
||||
payload = _read_confined_file(path, root, max(1, sidecar_stat.st_size))
|
||||
document = _decode_prepared_sidecar(payload)
|
||||
manifest = _manifest_from_sidecar(document, artifact, epoch_paths)
|
||||
identity = _prepared_source_identity(
|
||||
@@ -583,7 +583,7 @@ def _epoch_from_sidecar(
|
||||
segment_documents = value.get("segments")
|
||||
if (
|
||||
not isinstance(segment_documents, list)
|
||||
or not 1 <= len(segment_documents) <= MAX_MEDIA_SEGMENTS
|
||||
or not segment_documents
|
||||
):
|
||||
raise SessionIntegrityError("recorded media prepared segments are invalid")
|
||||
segments_root = epoch_path / "segments"
|
||||
@@ -652,8 +652,8 @@ def _canonical_json(value: object) -> bytes:
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SessionIntegrityError("recorded media preparation cannot be encoded") from exc
|
||||
if not 0 < len(encoded) <= MAX_PREPARED_MEDIA_SIDECAR_BYTES:
|
||||
raise SessionIntegrityError("recorded media preparation is outside bounds")
|
||||
if not encoded:
|
||||
raise SessionIntegrityError("recorded media preparation is empty")
|
||||
return encoded
|
||||
|
||||
|
||||
@@ -887,7 +887,7 @@ def _read_epoch(
|
||||
origin_epoch_ns: int,
|
||||
origin_monotonic_ns: int,
|
||||
) -> RecordedMediaEpoch:
|
||||
summary = _read_json_object(epoch / "summary.json", MAX_MEDIA_INDEX_BYTES)
|
||||
summary = _read_json_object(epoch / "summary.json", MAX_MEDIA_SUMMARY_BYTES)
|
||||
if (
|
||||
summary.get("schema_version") != CAMERA_ARCHIVE_SCHEMA
|
||||
or summary.get("source_id") != expected_source_name
|
||||
@@ -895,7 +895,10 @@ def _read_epoch(
|
||||
):
|
||||
raise SessionIntegrityError("recorded media summary is incompatible")
|
||||
segment_count = summary.get("segment_count")
|
||||
if not _non_negative_int(segment_count) or not 1 <= int(segment_count) <= MAX_MEDIA_SEGMENTS:
|
||||
if (
|
||||
not _non_negative_int(segment_count)
|
||||
or not 1 <= int(segment_count) <= MAX_SAFE_INTEGER
|
||||
):
|
||||
raise SessionIntegrityError("recorded media segment count is invalid")
|
||||
match = _EPOCH_PATTERN.fullmatch(epoch.name)
|
||||
if (
|
||||
@@ -913,39 +916,17 @@ def _read_epoch(
|
||||
):
|
||||
raise SessionIntegrityError("recorded media summary aggregate is inconsistent")
|
||||
|
||||
try:
|
||||
raw_index = _read_confined_file(
|
||||
epoch / "index.jsonl",
|
||||
epoch,
|
||||
MAX_MEDIA_INDEX_BYTES,
|
||||
)
|
||||
raw_lines = raw_index.decode("utf-8").splitlines()
|
||||
except UnicodeDecodeError as exc:
|
||||
raise SessionIntegrityError("recorded media index is unavailable") from exc
|
||||
entries, index_sha256 = _read_media_index(
|
||||
epoch / "index.jsonl",
|
||||
epoch,
|
||||
expected_count=int(segment_count),
|
||||
)
|
||||
expected_index_sha256 = summary.get("index_sha256")
|
||||
if (
|
||||
not isinstance(expected_index_sha256, str)
|
||||
or hashlib.sha256(raw_index).hexdigest() != expected_index_sha256
|
||||
or index_sha256 != expected_index_sha256
|
||||
):
|
||||
raise SessionIntegrityError("recorded media index digest changed")
|
||||
if len(raw_lines) != int(segment_count):
|
||||
raise SessionIntegrityError("recorded media index length does not match its summary")
|
||||
entries: list[dict[str, Any]] = []
|
||||
for sequence, line in enumerate(raw_lines, start=1):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise SessionIntegrityError("recorded media index contains invalid JSON") from exc
|
||||
if not isinstance(entry, dict):
|
||||
raise SessionIntegrityError("recorded media index entry is not an object")
|
||||
if (
|
||||
entry.get("schema_version") != CAMERA_INDEX_SCHEMA
|
||||
or entry.get("sequence") != sequence
|
||||
or entry.get("kind") != "media"
|
||||
or entry.get("path") != f"segments/{sequence}.m4s"
|
||||
):
|
||||
raise SessionIntegrityError("recorded media index entry is inconsistent")
|
||||
entries.append(entry)
|
||||
|
||||
use_monotonic_clock = all(
|
||||
_non_negative_int(entry.get("host_monotonic_ns")) for entry in entries
|
||||
@@ -1572,6 +1553,81 @@ def _read_first_confined_line(path: Path, parent: Path, maximum_bytes: int) -> b
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _read_media_index(
|
||||
path: Path,
|
||||
parent: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
) -> tuple[list[dict[str, Any]], str]:
|
||||
"""Stream a sealed JSONL index without imposing a recording-duration cap."""
|
||||
|
||||
try:
|
||||
resolved_parent = parent.resolve(strict=True)
|
||||
if path.parent.resolve(strict=True) != resolved_parent:
|
||||
raise SessionIntegrityError("recorded media index escapes its epoch")
|
||||
parent_fd = os.open(
|
||||
resolved_parent,
|
||||
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
|
||||
)
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("recorded media index is missing") from exc
|
||||
descriptor = -1
|
||||
try:
|
||||
descriptor = os.open(
|
||||
path.name,
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_size <= 0:
|
||||
raise SessionIntegrityError("recorded media index is not regular")
|
||||
entries: list[dict[str, Any]] = []
|
||||
digest = hashlib.sha256()
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
descriptor = -1
|
||||
for sequence in range(1, expected_count + 1):
|
||||
raw_line = stream.readline(MAX_MEDIA_INDEX_LINE_BYTES + 1)
|
||||
if not raw_line or len(raw_line) > MAX_MEDIA_INDEX_LINE_BYTES:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media index line is missing or outside bounds"
|
||||
)
|
||||
if not raw_line.endswith(b"\n"):
|
||||
raise SessionIntegrityError("recorded media index has an incomplete line")
|
||||
digest.update(raw_line)
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise SessionIntegrityError(
|
||||
"recorded media index contains invalid JSON"
|
||||
) from exc
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or entry.get("schema_version") != CAMERA_INDEX_SCHEMA
|
||||
or entry.get("sequence") != sequence
|
||||
or entry.get("kind") != "media"
|
||||
or entry.get("path") != f"segments/{sequence}.m4s"
|
||||
):
|
||||
raise SessionIntegrityError("recorded media index entry is inconsistent")
|
||||
entries.append(entry)
|
||||
if stream.read(1):
|
||||
raise SessionIntegrityError(
|
||||
"recorded media index length does not match its summary"
|
||||
)
|
||||
after = os.fstat(stream.fileno())
|
||||
if (
|
||||
(after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
!= (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||
):
|
||||
raise SessionIntegrityError("recorded media index changed during validation")
|
||||
return entries, digest.hexdigest()
|
||||
except OSError as exc:
|
||||
raise SessionIntegrityError("recorded media index is unavailable") from exc
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _read_json_object(path: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(_read_confined_file(path, path.parent, maximum_bytes))
|
||||
|
||||
@@ -23,15 +23,18 @@ from .plugin_contract import (
|
||||
RecordingExporter,
|
||||
)
|
||||
|
||||
# v9 derives both real RRD boundary rows from the durable capture-clock
|
||||
# envelope. v8 used the first/last MQTT message and could still exclude a
|
||||
# camera fragment produced between source startup/shutdown and those packets.
|
||||
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v9"
|
||||
# v12 publishes a 2 Hz point-cloud operator projection while retaining complete
|
||||
# normal K1 point batches. v11 also spatially thinned every ~2.4k-point frame;
|
||||
# the latest-at AI view therefore looked visibly bald even though the raw
|
||||
# capture was complete. Frames above the explicit emergency threshold remain
|
||||
# bounded, and the native capture stays the source of record and AI input.
|
||||
CACHE_SCHEMA = "missioncore.derived-rerun-recording-cache/v12"
|
||||
COMPATIBLE_CACHE_SCHEMAS = frozenset({CACHE_SCHEMA})
|
||||
RECORDING_CACHE_FILENAME = "scene.operator-v12.rrd"
|
||||
RECORDING_CACHE_SIDECAR_FILENAME = f"{RECORDING_CACHE_FILENAME}.cache.json"
|
||||
RERUN_RECORDING_MEDIA_TYPE = "application/vnd.rerun.rrd"
|
||||
RERUN_SESSION_TIMELINE = "session_time"
|
||||
SESSION_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
DEFAULT_CACHE_MAX_BYTES = 8 * 1024 * 1024 * 1024
|
||||
DEFAULT_FREE_SPACE_RESERVE_BYTES = 2 * 1024 * 1024 * 1024
|
||||
|
||||
RrdExporter = Callable[..., Mapping[str, object]]
|
||||
@@ -154,10 +157,9 @@ class SessionRecordingMaterializer:
|
||||
self._exporters = dict(exporters or {})
|
||||
if len(self._exporters) != len(set(self._exporters)):
|
||||
raise ValueError("recording exporter plugin ids must be unique")
|
||||
self.cache_max_bytes = _positive_configuration(
|
||||
self.cache_max_bytes = _optional_positive_configuration(
|
||||
cache_max_bytes,
|
||||
environment_name="MISSIONCORE_RRD_CACHE_MAX_BYTES",
|
||||
default=DEFAULT_CACHE_MAX_BYTES,
|
||||
)
|
||||
self.free_space_reserve_bytes = _non_negative_configuration(
|
||||
free_space_reserve_bytes,
|
||||
@@ -198,7 +200,7 @@ class SessionRecordingMaterializer:
|
||||
return False
|
||||
return (
|
||||
path.parent.parent == self.recordings_root
|
||||
and path.name == "scene.rrd"
|
||||
and path.name == RECORDING_CACHE_FILENAME
|
||||
and stat.S_ISREG(path_stat.st_mode)
|
||||
and not stat.S_ISLNK(path_stat.st_mode)
|
||||
and path_stat.st_size == recording.byte_length
|
||||
@@ -425,7 +427,10 @@ class SessionRecordingMaterializer:
|
||||
and name.endswith(".tmp")
|
||||
)
|
||||
or (name.startswith(".source.") and name.endswith(".tmp"))
|
||||
or (name.startswith(".scene.rrd.cache.json.") and name.endswith(".tmp"))
|
||||
or (
|
||||
name.startswith(f".{RECORDING_CACHE_SIDECAR_FILENAME}.")
|
||||
and name.endswith(".tmp")
|
||||
)
|
||||
)
|
||||
if not stale:
|
||||
continue
|
||||
@@ -464,8 +469,8 @@ class SessionRecordingMaterializer:
|
||||
_chmod_best_effort(resolved_session_root, 0o700)
|
||||
return (
|
||||
resolved_session_root,
|
||||
resolved_session_root / "scene.rrd",
|
||||
resolved_session_root / "scene.rrd.cache.json",
|
||||
resolved_session_root / RECORDING_CACHE_FILENAME,
|
||||
resolved_session_root / RECORDING_CACHE_SIDECAR_FILENAME,
|
||||
)
|
||||
|
||||
def _load_cached_recording(
|
||||
@@ -609,7 +614,10 @@ class SessionRecordingMaterializer:
|
||||
candidate_path,
|
||||
"derived recording candidate",
|
||||
)
|
||||
if candidate_stat.st_size > self.cache_max_bytes:
|
||||
if (
|
||||
self.cache_max_bytes is not None
|
||||
and candidate_stat.st_size > self.cache_max_bytes
|
||||
):
|
||||
raise RecordingMaterializationError("derived recording exceeds the cache quota")
|
||||
self._ensure_cache_capacity(
|
||||
required_bytes=4 * 1024,
|
||||
@@ -802,7 +810,10 @@ class SessionRecordingMaterializer:
|
||||
while True:
|
||||
total_bytes, entries = _cache_entries(self.recordings_root)
|
||||
free_bytes = shutil.disk_usage(self.recordings_root).free
|
||||
quota_ok = total_bytes + required_bytes <= self.cache_max_bytes
|
||||
quota_ok = (
|
||||
self.cache_max_bytes is None
|
||||
or total_bytes + required_bytes <= self.cache_max_bytes
|
||||
)
|
||||
reserve_ok = free_bytes >= self.free_space_reserve_bytes + required_bytes
|
||||
if quota_ok and reserve_ok:
|
||||
return
|
||||
@@ -1372,16 +1383,17 @@ def _touch_lru(path: Path) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _positive_configuration(
|
||||
def _optional_positive_configuration(
|
||||
configured: int | None,
|
||||
*,
|
||||
environment_name: str,
|
||||
default: int,
|
||||
) -> int:
|
||||
) -> int | None:
|
||||
value = configured
|
||||
if value is None:
|
||||
raw = os.environ.get(environment_name, "").strip()
|
||||
value = int(raw) if raw else default
|
||||
value = int(raw) if raw else None
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool) or value <= 0:
|
||||
raise ValueError(f"{environment_name} must be a positive integer")
|
||||
return value
|
||||
|
||||
@@ -30,6 +30,12 @@ class MetricsSnapshot(TypedDict):
|
||||
device_pgo_progress: int | None
|
||||
modeling_reports: int
|
||||
modeling_decode_errors: int
|
||||
perception_frames: int
|
||||
perception_dropped: int
|
||||
perception_fps: float
|
||||
perception_end_to_end_ms: float | None
|
||||
perception_end_to_end_p95_ms: float | None
|
||||
perception_stale_ms: float | None
|
||||
|
||||
|
||||
class BridgeMetrics:
|
||||
@@ -56,6 +62,11 @@ class BridgeMetrics:
|
||||
self._device_pgo_progress: int | None = None
|
||||
self._modeling_reports = 0
|
||||
self._modeling_decode_errors = 0
|
||||
self._perception_frames = 0
|
||||
self._perception_dropped = 0
|
||||
self._perception_times: deque[int] = deque()
|
||||
self._perception_latencies_ms: deque[float] = deque(maxlen=512)
|
||||
self._perception_last_publish_monotonic_ns: int | None = None
|
||||
|
||||
def received(self, payload_bytes: int) -> None:
|
||||
with self._lock:
|
||||
@@ -93,6 +104,26 @@ class BridgeMetrics:
|
||||
with self._lock:
|
||||
self._preview_dropped += 1
|
||||
|
||||
def perception_dropped(self) -> None:
|
||||
with self._lock:
|
||||
self._perception_dropped += 1
|
||||
|
||||
def published_perception(
|
||||
self,
|
||||
*,
|
||||
captured_at_epoch_ns: int,
|
||||
published_at_epoch_ns: int,
|
||||
published_monotonic_ns: int,
|
||||
) -> None:
|
||||
latency_ms = (published_at_epoch_ns - captured_at_epoch_ns) / 1_000_000
|
||||
with self._lock:
|
||||
self._perception_frames += 1
|
||||
self._perception_times.append(published_monotonic_ns)
|
||||
_trim_rate_window(self._perception_times, published_monotonic_ns)
|
||||
self._perception_last_publish_monotonic_ns = published_monotonic_ns
|
||||
if math.isfinite(latency_ms) and latency_ms >= 0:
|
||||
self._perception_latencies_ms.append(latency_ms)
|
||||
|
||||
def acquisition_telemetry(
|
||||
self,
|
||||
*,
|
||||
@@ -125,7 +156,9 @@ class BridgeMetrics:
|
||||
with self._lock:
|
||||
_trim_rate_window(self._pcl_times, now_ns)
|
||||
_trim_rate_window(self._pose_times, now_ns)
|
||||
_trim_rate_window(self._perception_times, now_ns)
|
||||
latencies = list(self._latencies_ms)
|
||||
perception_latencies = list(self._perception_latencies_ms)
|
||||
last_latency = latencies[-1] if latencies else None
|
||||
p50 = statistics.median(latencies) if latencies else None
|
||||
p95 = _percentile(latencies, 0.95) if latencies else None
|
||||
@@ -151,6 +184,22 @@ class BridgeMetrics:
|
||||
"device_pgo_progress": self._device_pgo_progress,
|
||||
"modeling_reports": self._modeling_reports,
|
||||
"modeling_decode_errors": self._modeling_decode_errors,
|
||||
"perception_frames": self._perception_frames,
|
||||
"perception_dropped": self._perception_dropped,
|
||||
"perception_fps": _window_rate(self._perception_times),
|
||||
"perception_end_to_end_ms": _rounded(
|
||||
perception_latencies[-1] if perception_latencies else None
|
||||
),
|
||||
"perception_end_to_end_p95_ms": _rounded(
|
||||
_percentile(perception_latencies, 0.95)
|
||||
if perception_latencies
|
||||
else None
|
||||
),
|
||||
"perception_stale_ms": _rounded(
|
||||
None
|
||||
if self._perception_last_publish_monotonic_ns is None
|
||||
else (now_ns - self._perception_last_publish_monotonic_ns) / 1_000_000
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+179
-14
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import rerun as rr
|
||||
@@ -15,11 +15,29 @@ APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
|
||||
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID = UUID("1480abcc-a0ae-4287-ab69-4b606d15d947")
|
||||
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
|
||||
RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID = UUID("27d95ad7-1e72-46ca-b854-d02cd99b7610")
|
||||
RECORDED_PERCEPTION_ROOT_CONTAINER_ID = UUID("70ea9fd5-bbbb-4b23-8ae8-8098af92b997")
|
||||
RECORDED_PERCEPTION_RESET_ROOT_CONTAINER_ID = UUID("d2196c6e-99da-4402-857c-4daea0dd3ae0")
|
||||
RECORDED_PERCEPTION_3D_ROOT_CONTAINER_ID = UUID("81051015-9808-413b-90a4-1cfaacacbc4f")
|
||||
RECORDED_PERCEPTION_3D_RESET_ROOT_CONTAINER_ID = UUID(
|
||||
"3de10822-0274-4a58-8d45-f35d86625303"
|
||||
)
|
||||
RECORDED_METRICS_ROOT_CONTAINER_ID = UUID("374710a2-1d77-4349-bea4-8719e7aa1f09")
|
||||
RECORDED_METRICS_RESET_ROOT_CONTAINER_ID = UUID("1e8ac565-bbd6-4554-9213-dad564e534e3")
|
||||
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
|
||||
RECORDED_PERCEPTION_POINTS_VISUALIZER_ID = UUID(
|
||||
"ab8db306-6981-49c5-b417-94fe2f01dbf0"
|
||||
)
|
||||
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||
RECORDED_CAMERA_RESET_VIEW_ID = UUID("d0618e0c-c889-4222-a643-60a4a882935c")
|
||||
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID = UUID("31f63ab8-ffdd-4751-ae40-e8e7b4462c8f")
|
||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||
RecordedView = Literal["spatial", "perception", "metrics"]
|
||||
RECORDED_UNIFIED_ROOT_CONTAINER_ID = UUID("e9934ef8-453f-432e-9136-2b0908190253")
|
||||
RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID = UUID("2781407d-9f4e-4405-86e8-bbe6065e83df")
|
||||
RecordedView = Literal["spatial", "perception", "perception3d", "metrics"]
|
||||
|
||||
|
||||
class RecordedBlueprintError(RuntimeError):
|
||||
@@ -31,6 +49,11 @@ def recorded_blueprint(
|
||||
*,
|
||||
include_initial_playback_state: bool = True,
|
||||
active_view: RecordedView = "spatial",
|
||||
view_reset_generation: Literal[0, 1] = 0,
|
||||
unified_perception: bool = False,
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
) -> rrb.Blueprint:
|
||||
accumulation = max(0.0, settings.accumulation_seconds)
|
||||
time_ranges: list[rr.VisibleTimeRange] | None = None
|
||||
@@ -42,6 +65,7 @@ def recorded_blueprint(
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
]
|
||||
latest_time_ranges = rrb.VisibleTimeRanges([])
|
||||
point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
@@ -51,9 +75,18 @@ def recorded_blueprint(
|
||||
),
|
||||
).visualizer()
|
||||
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
|
||||
perception_point_visualizer = rr.Points3D.from_fields(
|
||||
radii=rr.Radius.ui_points(settings.point_size),
|
||||
colors=(
|
||||
[_parse_hex_color(settings.custom_color)]
|
||||
if settings.palette == "custom"
|
||||
else None
|
||||
),
|
||||
).visualizer()
|
||||
perception_point_visualizer.id = RECORDED_PERCEPTION_POINTS_VISUALIZER_ID
|
||||
spatial_view = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Пространственная сцена",
|
||||
name="Мир · LiDAR и объекты" if unified_perception else "Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
@@ -68,29 +101,151 @@ def recorded_blueprint(
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
# Perception is hidden in the mapping-only presentation. Unified
|
||||
# perception below adds explicit per-entity latest-at overrides,
|
||||
# allowing the native cloud to retain this view's accumulation.
|
||||
"/world/perception": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
time_ranges=time_ranges,
|
||||
)
|
||||
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
|
||||
spatial_view.id = (
|
||||
RECORDED_SPATIAL_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_SPATIAL_VIEW_ID
|
||||
)
|
||||
camera_view = rrb.Spatial2DView(
|
||||
origin="/perception/camera",
|
||||
name="Камера · распознавание",
|
||||
name="Оригинальное видео · слои AI",
|
||||
background=[7, 8, 10, 255],
|
||||
overrides={
|
||||
"/perception/camera/image": rrb.EntityBehavior(visible=True),
|
||||
"/perception/camera/detections": rrb.EntityBehavior(
|
||||
visible=show_detections_2d,
|
||||
),
|
||||
"/perception/camera/segmentation": rrb.EntityBehavior(
|
||||
visible=show_segmentation,
|
||||
),
|
||||
},
|
||||
)
|
||||
camera_view.id = (
|
||||
RECORDED_CAMERA_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_CAMERA_VIEW_ID
|
||||
)
|
||||
perception_3d_view = rrb.Spatial3DView(
|
||||
# Cuboids are expressed in the same calibrated world frame as the
|
||||
# native LiDAR recording. Rooting this view at /world/perception
|
||||
# excluded /world/points and left the operator looking at boxes in an
|
||||
# empty scene. Keep the main point cloud and the derived objects in
|
||||
# one latest-at world view; unlike the mapping tab, this deliberately
|
||||
# has no accumulated time range, so dynamic cuboids never stack.
|
||||
origin="/world",
|
||||
name="Сегментация и объекты · 3D",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
"/world/points": [
|
||||
rrb.EntityBehavior(visible=settings.show_points),
|
||||
perception_point_visualizer,
|
||||
],
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
"/world/perception": rrb.EntityBehavior(visible=True),
|
||||
# The overlay already carries the selected fusion support points;
|
||||
# its grey diagnostic LiDAR copy would otherwise double-render the
|
||||
# native cloud from /world/points.
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
)
|
||||
perception_3d_view.id = (
|
||||
RECORDED_PERCEPTION_3D_RESET_VIEW_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_PERCEPTION_3D_VIEW_ID
|
||||
)
|
||||
camera_view.id = RECORDED_CAMERA_VIEW_ID
|
||||
metrics_view = rrb.TimeSeriesView(
|
||||
origin="/metrics/device",
|
||||
name="Маршрут и время",
|
||||
)
|
||||
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
||||
active_tab = {"spatial": 0, "perception": 1, "metrics": 2}[active_view]
|
||||
root_container = rrb.Tabs(
|
||||
spatial_view,
|
||||
camera_view,
|
||||
metrics_view,
|
||||
active_tab=active_tab,
|
||||
)
|
||||
root_container.id = RECORDED_ROOT_CONTAINER_ID
|
||||
root_container: Any
|
||||
if unified_perception:
|
||||
# Operator perception is one synchronized composition, not a set of
|
||||
# mutually exclusive modes. The camera keeps the original frame as
|
||||
# its base and overlays 2D detections/segmentation. The paired world
|
||||
# view shows the same cursor in the native LiDAR frame and reveals
|
||||
# semantic points and cuboids independently.
|
||||
spatial_view.visualizer_overrides["/world/perception"] = [
|
||||
rrb.EntityBehavior(visible=True),
|
||||
latest_time_ranges,
|
||||
]
|
||||
spatial_view.visualizer_overrides[
|
||||
"/world/perception/lidar"
|
||||
] = [
|
||||
rrb.EntityBehavior(visible=False),
|
||||
latest_time_ranges,
|
||||
]
|
||||
spatial_view.visualizer_overrides[
|
||||
"/world/perception/support"
|
||||
] = [
|
||||
rrb.EntityBehavior(visible=False),
|
||||
latest_time_ranges,
|
||||
]
|
||||
spatial_view.visualizer_overrides[
|
||||
"/world/perception/semantic_points"
|
||||
] = [
|
||||
rrb.EntityBehavior(visible=show_segmentation),
|
||||
latest_time_ranges,
|
||||
]
|
||||
spatial_view.visualizer_overrides[
|
||||
"/world/perception/boxes3d"
|
||||
] = [
|
||||
rrb.EntityBehavior(visible=show_cuboids_3d),
|
||||
latest_time_ranges,
|
||||
]
|
||||
root_container = rrb.Horizontal(
|
||||
camera_view,
|
||||
spatial_view,
|
||||
column_shares=[0.46, 0.54],
|
||||
name="Единая сцена восприятия",
|
||||
)
|
||||
root_container.id = (
|
||||
RECORDED_UNIFIED_RESET_ROOT_CONTAINER_ID
|
||||
if view_reset_generation
|
||||
else RECORDED_UNIFIED_ROOT_CONTAINER_ID
|
||||
)
|
||||
else:
|
||||
# Keep operator video and 3D cuboids as direct root views. Rerun's nested
|
||||
# Tabs preserve their own active child and cannot be switched reliably by
|
||||
# a live blueprint channel after the operator has visited another child.
|
||||
active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[
|
||||
active_view
|
||||
]
|
||||
root_container = rrb.Tabs(
|
||||
spatial_view,
|
||||
camera_view,
|
||||
perception_3d_view,
|
||||
metrics_view,
|
||||
active_tab=active_tab,
|
||||
)
|
||||
# Rerun persists the active child on a Tabs container and does not reliably
|
||||
# replace it from a later blueprint message. Give every operator mode (and
|
||||
# its explicit reset generation) a stable root identity so the requested
|
||||
# child is authoritative instead of inheriting a previously visited tab.
|
||||
root_container.id = {
|
||||
("spatial", 0): RECORDED_ROOT_CONTAINER_ID,
|
||||
("spatial", 1): RECORDED_SPATIAL_RESET_ROOT_CONTAINER_ID,
|
||||
("perception", 0): RECORDED_PERCEPTION_ROOT_CONTAINER_ID,
|
||||
("perception", 1): RECORDED_PERCEPTION_RESET_ROOT_CONTAINER_ID,
|
||||
("perception3d", 0): RECORDED_PERCEPTION_3D_ROOT_CONTAINER_ID,
|
||||
("perception3d", 1): RECORDED_PERCEPTION_3D_RESET_ROOT_CONTAINER_ID,
|
||||
("metrics", 0): RECORDED_METRICS_ROOT_CONTAINER_ID,
|
||||
("metrics", 1): RECORDED_METRICS_RESET_ROOT_CONTAINER_ID,
|
||||
}[(active_view, view_reset_generation)]
|
||||
|
||||
if include_initial_playback_state:
|
||||
return rrb.Blueprint(
|
||||
@@ -118,6 +273,11 @@ def recorded_blueprint_rrd(
|
||||
application_id: str = APPLICATION_ID,
|
||||
recording_id: str,
|
||||
active_view: RecordedView = "spatial",
|
||||
view_reset_generation: Literal[0, 1] = 0,
|
||||
unified_perception: bool = False,
|
||||
show_detections_2d: bool = False,
|
||||
show_segmentation: bool = False,
|
||||
show_cuboids_3d: bool = False,
|
||||
) -> bytes:
|
||||
"""Serialize a bounded active blueprint update without recorded data."""
|
||||
|
||||
@@ -133,6 +293,11 @@ def recorded_blueprint_rrd(
|
||||
settings,
|
||||
include_initial_playback_state=False,
|
||||
active_view=active_view,
|
||||
view_reset_generation=view_reset_generation,
|
||||
unified_perception=unified_perception,
|
||||
show_detections_2d=show_detections_2d,
|
||||
show_segmentation=show_segmentation,
|
||||
show_cuboids_3d=show_cuboids_3d,
|
||||
),
|
||||
make_active=True,
|
||||
make_default=False,
|
||||
|
||||
@@ -2,16 +2,18 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from contextlib import suppress
|
||||
from dataclasses import asdict, dataclass
|
||||
from typing import Literal
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import rerun as rr
|
||||
from rerun import blueprint as rrb
|
||||
from rerun.components import FillMode
|
||||
|
||||
from k1link.compute.live_perception import LivePerceptionResultFrame
|
||||
from k1link.data_plane import (
|
||||
DecodedDataPlaneView,
|
||||
DecodedPointCloudView,
|
||||
@@ -49,6 +51,9 @@ class RerunSceneSettings:
|
||||
show_points: bool = True
|
||||
show_trajectory: bool = True
|
||||
show_grid: bool = True
|
||||
show_detections_2d: bool = False
|
||||
show_segmentation: bool = False
|
||||
show_cuboids_3d: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
@@ -188,6 +193,82 @@ class RerunBridge:
|
||||
if context.live and context.received_monotonic_ns is not None:
|
||||
self.metrics.record_latency((published_ns - context.received_monotonic_ns) / 1_000_000)
|
||||
|
||||
def process_perception(self, frame: LivePerceptionResultFrame) -> None:
|
||||
"""Publish one validated worker result on the live scene timeline."""
|
||||
|
||||
self._apply_latest_settings()
|
||||
self._recording.set_time(
|
||||
"stream_time",
|
||||
timestamp=frame.captured_at_epoch_ns / 1_000_000_000,
|
||||
)
|
||||
self._recording.set_time(
|
||||
"capture_time",
|
||||
timestamp=frame.captured_at_epoch_ns / 1_000_000_000,
|
||||
)
|
||||
self._recording.set_time("message_sequence", sequence=frame.source_frame_index)
|
||||
self._recording.log(
|
||||
"/perception/camera/image",
|
||||
rr.EncodedImage(contents=frame.image_jpeg, media_type="image/jpeg"),
|
||||
)
|
||||
if frame.segmentation_mask is None:
|
||||
self._recording.log(
|
||||
"/perception/camera/segmentation",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
else:
|
||||
self._recording.log(
|
||||
"/perception/camera/segmentation",
|
||||
rr.SegmentationImage(frame.segmentation_mask),
|
||||
)
|
||||
if frame.objects:
|
||||
self._recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Boxes2D(
|
||||
array=[item["bbox_xyxy"] for item in frame.objects],
|
||||
array_format=rr.Box2DFormat.XYXY,
|
||||
labels=[_perception_label(item) for item in frame.objects],
|
||||
colors=[
|
||||
_perception_color(str(item["label"]), alpha=255)
|
||||
for item in frame.objects
|
||||
],
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._recording.log(
|
||||
"/perception/camera/detections",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
cuboids = [
|
||||
item for item in frame.objects if item.get("cuboid_center_map") is not None
|
||||
]
|
||||
if cuboids:
|
||||
self._recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Boxes3D(
|
||||
centers=[item["cuboid_center_map"] for item in cuboids],
|
||||
half_sizes=[item["cuboid_half_size"] for item in cuboids],
|
||||
quaternions=[
|
||||
rr.Quaternion(xyzw=item["cuboid_quaternion_xyzw"])
|
||||
for item in cuboids
|
||||
],
|
||||
colors=[_perception_color(str(item["label"]), alpha=96) for item in cuboids],
|
||||
labels=[_perception_label(item) for item in cuboids],
|
||||
fill_mode=FillMode.Solid,
|
||||
show_labels=True,
|
||||
),
|
||||
)
|
||||
else:
|
||||
self._recording.log(
|
||||
"/world/perception/boxes3d",
|
||||
rr.Clear(recursive=False),
|
||||
)
|
||||
self.metrics.published_perception(
|
||||
captured_at_epoch_ns=frame.captured_at_epoch_ns,
|
||||
published_at_epoch_ns=time.time_ns(),
|
||||
published_monotonic_ns=time.monotonic_ns(),
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
@@ -324,18 +405,51 @@ def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
|
||||
end=rr.TimeRangeBoundary.cursor_relative(),
|
||||
)
|
||||
return rrb.Blueprint(
|
||||
rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
time_ranges=[time_range],
|
||||
perception_active = (
|
||||
settings.show_detections_2d
|
||||
or settings.show_segmentation
|
||||
or settings.show_cuboids_3d
|
||||
)
|
||||
spatial = rrb.Spatial3DView(
|
||||
origin="/world",
|
||||
name="Мир · LiDAR и объекты" if perception_active else "Пространственная сцена",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
time_ranges=[] if perception_active else [time_range],
|
||||
)
|
||||
spatial.visualizer_overrides["/world/points"] = rrb.EntityBehavior(
|
||||
visible=settings.show_points
|
||||
)
|
||||
spatial.visualizer_overrides["/world/trajectory"] = rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory
|
||||
)
|
||||
spatial.visualizer_overrides["/world/perception/boxes3d"] = rrb.EntityBehavior(
|
||||
visible=settings.show_cuboids_3d
|
||||
)
|
||||
camera = rrb.Spatial2DView(
|
||||
origin="/perception/camera",
|
||||
name="Оригинальное видео · слои AI",
|
||||
)
|
||||
camera.visualizer_overrides["/perception/camera/image"] = rrb.EntityBehavior(
|
||||
visible=True
|
||||
)
|
||||
camera.visualizer_overrides["/perception/camera/detections"] = rrb.EntityBehavior(
|
||||
visible=settings.show_detections_2d
|
||||
)
|
||||
camera.visualizer_overrides["/perception/camera/segmentation"] = rrb.EntityBehavior(
|
||||
visible=settings.show_segmentation
|
||||
)
|
||||
root = (
|
||||
rrb.Horizontal(camera, spatial, column_shares=[0.46, 0.54])
|
||||
if perception_active
|
||||
else spatial
|
||||
)
|
||||
return rrb.Blueprint(
|
||||
root,
|
||||
_live_time_panel(),
|
||||
auto_layout=False,
|
||||
auto_views=False,
|
||||
@@ -343,6 +457,25 @@ def _blueprint(settings: RerunSceneSettings) -> rrb.Blueprint:
|
||||
)
|
||||
|
||||
|
||||
def _perception_label(item: Mapping[str, Any]) -> str:
|
||||
base = f"#{int(item['track_id'])} {item['label']} · {float(item['score']):.0%}"
|
||||
distance = item.get("distance_m")
|
||||
return base if distance is None else f"{base} · {float(distance):.1f} m"
|
||||
|
||||
|
||||
def _perception_color(label: str, *, alpha: int) -> list[int]:
|
||||
colors = {
|
||||
"person": (255, 99, 132),
|
||||
"car": (64, 180, 255),
|
||||
"truck": (255, 180, 64),
|
||||
"bus": (255, 210, 64),
|
||||
"bicycle": (110, 240, 155),
|
||||
"motorcycle": (170, 115, 255),
|
||||
}
|
||||
red, green, blue = colors.get(label, (247, 248, 244))
|
||||
return [red, green, blue, alpha]
|
||||
|
||||
|
||||
def _live_time_panel() -> rrb.TimePanel:
|
||||
"""Keep the hidden vendor timeline on its native live edge."""
|
||||
return rrb.TimePanel(
|
||||
|
||||
+79
-6
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
@@ -14,7 +15,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.compute import RecordedPerceptionOverlayStore
|
||||
from k1link.compute import (
|
||||
IntegratedPerceptionOverlayStore,
|
||||
RecordedCalibratedFusionStore,
|
||||
RecordedPerceptionEpochStore,
|
||||
RecordedPerceptionOverlayMux,
|
||||
RecordedPerceptionOverlayStore,
|
||||
)
|
||||
from k1link.sessions import (
|
||||
MaterializedRecording,
|
||||
RecordedMediaInspector,
|
||||
@@ -42,6 +49,22 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[3]
|
||||
INVALID_REQUEST_DETAIL = "Некорректные параметры запроса."
|
||||
|
||||
|
||||
def _resolve_media_tool(name: str) -> Path | None:
|
||||
"""Resolve media tools under interactive shells and minimal launchd PATHs."""
|
||||
|
||||
discovered = shutil.which(name)
|
||||
candidates = (
|
||||
Path(discovered) if discovered is not None else None,
|
||||
Path("/opt/homebrew/bin") / name,
|
||||
Path("/usr/local/bin") / name,
|
||||
Path("/usr/bin") / name,
|
||||
)
|
||||
for candidate in candidates:
|
||||
if candidate is not None and candidate.is_file() and os.access(candidate, os.X_OK):
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
@@ -53,19 +76,68 @@ session_recording_materializer = SessionRecordingMaterializer(
|
||||
session_recorded_media_inspector = RecordedMediaInspector(
|
||||
session_store.data_dir / "recorded-media-preparations"
|
||||
)
|
||||
_ffmpeg = shutil.which("ffmpeg")
|
||||
_ffprobe = shutil.which("ffprobe")
|
||||
session_perception_overlay_store = (
|
||||
_ffmpeg = _resolve_media_tool("ffmpeg")
|
||||
_ffprobe = _resolve_media_tool("ffprobe")
|
||||
session_legacy_perception_overlay_store = (
|
||||
RecordedPerceptionOverlayStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
cache_root=session_store.data_dir / "perception-overlays",
|
||||
ffmpeg_path=Path(_ffmpeg),
|
||||
ffprobe_path=Path(_ffprobe),
|
||||
ffmpeg_path=_ffmpeg,
|
||||
ffprobe_path=_ffprobe,
|
||||
)
|
||||
if _ffmpeg is not None and _ffprobe is not None
|
||||
else None
|
||||
)
|
||||
session_calibrated_fusion_store = RecordedCalibratedFusionStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
perception_results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
fusion_results_root=REPOSITORY_ROOT / ".runtime" / "compute-fusions",
|
||||
cache_root=session_store.data_dir / "calibrated-fusion-overlays",
|
||||
)
|
||||
session_previous_perception_overlay_store = RecordedPerceptionOverlayMux(
|
||||
session_calibrated_fusion_store, session_legacy_perception_overlay_store
|
||||
)
|
||||
session_integrated_perception_store = (
|
||||
IntegratedPerceptionOverlayStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=(
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e10"
|
||||
/ "worker-results"
|
||||
),
|
||||
lidar_packs_root=(
|
||||
REPOSITORY_ROOT
|
||||
/ ".runtime"
|
||||
/ "compute-experiments"
|
||||
/ "e10"
|
||||
/ "lidar-packs"
|
||||
),
|
||||
cache_root=session_store.data_dir / "integrated-perception-overlays",
|
||||
ffmpeg_path=_ffmpeg,
|
||||
)
|
||||
if _ffmpeg is not None
|
||||
else None
|
||||
)
|
||||
session_perception_overlay_store = RecordedPerceptionOverlayMux(
|
||||
session_integrated_perception_store or session_previous_perception_overlay_store,
|
||||
(
|
||||
session_previous_perception_overlay_store
|
||||
if session_integrated_perception_store is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
session_perception_epoch_store = (
|
||||
RecordedPerceptionEpochStore(
|
||||
jobs_root=REPOSITORY_ROOT / ".runtime" / "compute-jobs",
|
||||
results_root=REPOSITORY_ROOT / ".runtime" / "compute-results",
|
||||
ffprobe_path=_ffprobe,
|
||||
)
|
||||
if _ffprobe is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def _prepare_recorded_media_for_launch(
|
||||
@@ -320,6 +392,7 @@ app.include_router(
|
||||
recording_preparation_manager=session_recording_preparation_manager,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
perception_overlay_provider=session_perception_overlay_store,
|
||||
perception_media_provider=session_perception_epoch_store,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ _EPOCH_DIRECTORY = re.compile(r"^epoch-([1-9][0-9]*)$")
|
||||
_SEGMENT_FILE = re.compile(r"^([1-9][0-9]*)\.m4s$")
|
||||
_DEFAULT_COMMIT_INTERVAL_SECONDS = 0.25
|
||||
_DEFAULT_COMMIT_BYTES = 4 * 1024 * 1024
|
||||
_MAX_RECOVERY_INDEX_BYTES = 32 * 1024 * 1024
|
||||
_MAX_RECOVERY_SUMMARY_BYTES = 2 * 1024 * 1024
|
||||
_MAX_RECOVERY_INDEX_LINE_BYTES = 64 * 1024
|
||||
_MAX_RECOVERY_SEGMENT_BYTES = 8 * 1024 * 1024
|
||||
_MAX_RECOVERY_SEGMENTS = 500_000
|
||||
|
||||
_ACTIVE_ARCHIVES_LOCK = threading.Lock()
|
||||
_ACTIVE_ARCHIVES: set[Path] = set()
|
||||
@@ -394,9 +394,11 @@ def recover_incomplete_camera_archives(
|
||||
``interrupted`` summary is atomically written.
|
||||
|
||||
This is intentionally a startup/catalog-refresh operation, not a hot-path
|
||||
operation: it hashes media fragments. The callable is safe to repeat, but a
|
||||
composition layer should normally execute it once per server process before
|
||||
the first catalog import.
|
||||
operation. A clean sealed epoch is validated from its summary, streaming
|
||||
index and segment stat metadata without loading payloads; only an incomplete
|
||||
or damaged epoch enters fragment recovery and hashes candidate payloads. The
|
||||
callable is safe to repeat, but a composition layer should normally execute
|
||||
it once per server process before the first catalog import.
|
||||
"""
|
||||
|
||||
root = sessions_root.expanduser().resolve()
|
||||
@@ -468,30 +470,25 @@ def _recover_epoch(
|
||||
if segments_fd is None:
|
||||
return None
|
||||
try:
|
||||
old_index = _read_regular_at(
|
||||
if _sealed_epoch_is_valid_on_disk(
|
||||
epoch_fd,
|
||||
segments_fd,
|
||||
source_id=source_id,
|
||||
init=init,
|
||||
):
|
||||
return None
|
||||
old_index = _read_regular_at_current_size(
|
||||
epoch_fd,
|
||||
"index.jsonl",
|
||||
_MAX_RECOVERY_INDEX_BYTES,
|
||||
allow_empty=True,
|
||||
)
|
||||
old_summary = _read_regular_at(
|
||||
epoch_fd,
|
||||
"summary.json",
|
||||
_MAX_RECOVERY_INDEX_BYTES,
|
||||
_MAX_RECOVERY_SUMMARY_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
segment_payloads, segment_timestamps, orphans = _read_recovery_segments(
|
||||
segments_fd
|
||||
)
|
||||
if _sealed_epoch_is_valid(
|
||||
source_id=source_id,
|
||||
summary_bytes=old_summary,
|
||||
index_bytes=old_index,
|
||||
segment_payloads=segment_payloads,
|
||||
orphans=orphans,
|
||||
):
|
||||
return None
|
||||
|
||||
segment_timestamps, orphans = _read_recovery_segment_catalog(segments_fd)
|
||||
old_entries = _parse_index_prefix(old_index)
|
||||
old_by_sequence = {
|
||||
int(entry["sequence"]): entry
|
||||
@@ -502,9 +499,17 @@ def _recover_epoch(
|
||||
stream_hash = hashlib.sha256(init)
|
||||
valid_bytes = len(init)
|
||||
expected = 1
|
||||
while expected <= _MAX_RECOVERY_SEGMENTS:
|
||||
payload = segment_payloads.get(expected)
|
||||
while True:
|
||||
if expected not in segment_timestamps:
|
||||
break
|
||||
payload = _read_regular_at(
|
||||
segments_fd,
|
||||
f"{expected}.m4s",
|
||||
_MAX_RECOVERY_SEGMENT_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if payload is None:
|
||||
orphans.append(f"{expected}.m4s")
|
||||
break
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
previous = old_by_sequence.get(expected)
|
||||
@@ -528,11 +533,10 @@ def _recover_epoch(
|
||||
valid_bytes += len(payload)
|
||||
expected += 1
|
||||
|
||||
valid_sequences = {int(entry["sequence"]) for entry in recovered_entries}
|
||||
orphans.extend(
|
||||
f"{sequence}.m4s"
|
||||
for sequence in segment_payloads
|
||||
if sequence not in valid_sequences
|
||||
for sequence in segment_timestamps
|
||||
if sequence >= expected
|
||||
)
|
||||
orphans = sorted(set(orphans))
|
||||
if not recovered_entries:
|
||||
@@ -611,48 +615,145 @@ def _recover_epoch(
|
||||
os.close(epoch_fd)
|
||||
|
||||
|
||||
def _sealed_epoch_is_valid(
|
||||
def _sealed_epoch_is_valid_on_disk(
|
||||
epoch_fd: int,
|
||||
segments_fd: int,
|
||||
*,
|
||||
source_id: str,
|
||||
summary_bytes: bytes | None,
|
||||
index_bytes: bytes | None,
|
||||
segment_payloads: dict[int, bytes],
|
||||
orphans: list[str],
|
||||
init: bytes,
|
||||
) -> bool:
|
||||
if not summary_bytes or index_bytes is None or orphans:
|
||||
"""Recognize a clean seal without loading a multi-hour archive into RAM.
|
||||
|
||||
Recovery only needs to prove that the durable commit envelope is complete.
|
||||
Full fragment digests and ISO-BMFF timing are revalidated by the recorded
|
||||
media preparation path before browser publication.
|
||||
"""
|
||||
|
||||
summary_bytes = _read_regular_at(
|
||||
epoch_fd,
|
||||
"summary.json",
|
||||
_MAX_RECOVERY_SUMMARY_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if not summary_bytes:
|
||||
return False
|
||||
try:
|
||||
summary = json.loads(summary_bytes)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(summary, dict) or summary.get("source_id") != source_id:
|
||||
return False
|
||||
segment_count = summary.get("segment_count")
|
||||
segment_count = summary.get("segment_count") if isinstance(summary, dict) else None
|
||||
if (
|
||||
not isinstance(segment_count, int)
|
||||
not isinstance(summary, dict)
|
||||
or summary.get("schema_version") != CAMERA_ARCHIVE_SCHEMA
|
||||
or summary.get("source_id") != source_id
|
||||
or not isinstance(segment_count, int)
|
||||
or isinstance(segment_count, bool)
|
||||
or segment_count < 1
|
||||
or set(segment_payloads) != set(range(1, segment_count + 1))
|
||||
or summary.get("entry_count") != segment_count
|
||||
or summary.get("media_segment_count") != segment_count
|
||||
or summary.get("commit_policy") != CAMERA_COMMIT_POLICY
|
||||
or summary.get("init_sha256") != hashlib.sha256(init).hexdigest()
|
||||
):
|
||||
return False
|
||||
entries = _parse_index_prefix(index_bytes)
|
||||
if len(entries) != segment_count:
|
||||
return False
|
||||
return all(
|
||||
_index_entry_matches(
|
||||
entry,
|
||||
sequence,
|
||||
len(segment_payloads[sequence]),
|
||||
hashlib.sha256(segment_payloads[sequence]).hexdigest(),
|
||||
|
||||
try:
|
||||
descriptor = os.open(
|
||||
"index.jsonl",
|
||||
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
||||
dir_fd=epoch_fd,
|
||||
)
|
||||
for sequence, entry in enumerate(entries, start=1)
|
||||
except OSError:
|
||||
return False
|
||||
digest = hashlib.sha256()
|
||||
valid_bytes = len(init)
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or before.st_size <= 0:
|
||||
return False
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
descriptor = -1
|
||||
for sequence in range(1, segment_count + 1):
|
||||
raw_line = stream.readline(_MAX_RECOVERY_INDEX_LINE_BYTES + 1)
|
||||
if (
|
||||
not raw_line
|
||||
or len(raw_line) > _MAX_RECOVERY_INDEX_LINE_BYTES
|
||||
or not raw_line.endswith(b"\n")
|
||||
):
|
||||
return False
|
||||
digest.update(raw_line)
|
||||
try:
|
||||
entry = json.loads(raw_line)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False
|
||||
length = entry.get("length") if isinstance(entry, dict) else None
|
||||
if (
|
||||
not isinstance(length, int)
|
||||
or isinstance(length, bool)
|
||||
or not 0 < length <= _MAX_RECOVERY_SEGMENT_BYTES
|
||||
or not _index_entry_shape_matches(entry, sequence)
|
||||
):
|
||||
return False
|
||||
try:
|
||||
segment_stat = os.stat(
|
||||
f"{sequence}.m4s",
|
||||
dir_fd=segments_fd,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
if not stat.S_ISREG(segment_stat.st_mode) or segment_stat.st_size != length:
|
||||
return False
|
||||
valid_bytes += length
|
||||
if stream.read(1):
|
||||
return False
|
||||
after = os.fstat(stream.fileno())
|
||||
if (
|
||||
(before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||
!= (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
):
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if descriptor >= 0:
|
||||
os.close(descriptor)
|
||||
try:
|
||||
segment_names = [
|
||||
name
|
||||
for name in os.listdir(segments_fd)
|
||||
if _SEGMENT_FILE.fullmatch(name) is not None
|
||||
]
|
||||
except OSError:
|
||||
return False
|
||||
if len(segment_names) != segment_count or any(
|
||||
name != f"{sequence}.m4s"
|
||||
for sequence, name in enumerate(
|
||||
sorted(segment_names, key=lambda name: int(name.removesuffix(".m4s"))),
|
||||
start=1,
|
||||
)
|
||||
):
|
||||
return False
|
||||
return (
|
||||
summary.get("index_sha256") == digest.hexdigest()
|
||||
and summary.get("valid_bytes") == valid_bytes
|
||||
)
|
||||
|
||||
|
||||
def _read_recovery_segments(
|
||||
def _index_entry_shape_matches(entry: object, sequence: int) -> bool:
|
||||
return (
|
||||
isinstance(entry, dict)
|
||||
and entry.get("schema_version") == CAMERA_INDEX_SCHEMA
|
||||
and entry.get("sequence") == sequence
|
||||
and entry.get("kind") == "media"
|
||||
and entry.get("path") == f"segments/{sequence}.m4s"
|
||||
and isinstance(entry.get("sha256"), str)
|
||||
and re.fullmatch(r"[a-f0-9]{64}", str(entry["sha256"])) is not None
|
||||
)
|
||||
|
||||
|
||||
def _read_recovery_segment_catalog(
|
||||
segments_fd: int,
|
||||
) -> tuple[dict[int, bytes], dict[int, int], list[str]]:
|
||||
payloads: dict[int, bytes] = {}
|
||||
) -> tuple[dict[int, int], list[str]]:
|
||||
timestamps: dict[int, int] = {}
|
||||
orphans: list[str] = []
|
||||
try:
|
||||
@@ -664,22 +765,22 @@ def _read_recovery_segments(
|
||||
if match is None:
|
||||
continue
|
||||
sequence = int(match.group(1))
|
||||
if name != f"{sequence}.m4s" or sequence in payloads:
|
||||
if name != f"{sequence}.m4s" or sequence in timestamps:
|
||||
orphans.append(name)
|
||||
continue
|
||||
read_result = _read_regular_with_metadata_at(
|
||||
segments_fd,
|
||||
name,
|
||||
_MAX_RECOVERY_SEGMENT_BYTES,
|
||||
allow_empty=False,
|
||||
)
|
||||
if read_result is None:
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=segments_fd, follow_symlinks=False)
|
||||
except OSError:
|
||||
orphans.append(name)
|
||||
continue
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or not 0 < metadata.st_size <= _MAX_RECOVERY_SEGMENT_BYTES
|
||||
):
|
||||
orphans.append(name)
|
||||
continue
|
||||
payload, metadata = read_result
|
||||
payloads[sequence] = payload
|
||||
timestamps[sequence] = metadata.st_mtime_ns
|
||||
return payloads, timestamps, orphans
|
||||
return timestamps, orphans
|
||||
|
||||
|
||||
def _parse_index_prefix(payload: bytes | None) -> list[dict[str, Any]]:
|
||||
@@ -908,6 +1009,28 @@ def _read_regular_at(
|
||||
return result[0] if result is not None else None
|
||||
|
||||
|
||||
def _read_regular_at_current_size(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
) -> bytes | None:
|
||||
"""Read one private recovery artifact without a duration-derived ceiling."""
|
||||
|
||||
try:
|
||||
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
||||
except OSError:
|
||||
return None
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
return None
|
||||
return _read_regular_at(
|
||||
parent_fd,
|
||||
name,
|
||||
max(1, metadata.st_size),
|
||||
allow_empty=allow_empty,
|
||||
)
|
||||
|
||||
|
||||
def _read_regular_with_metadata_at(
|
||||
parent_fd: int,
|
||||
name: str,
|
||||
|
||||
@@ -328,7 +328,7 @@ class AcquisitionRecord:
|
||||
control_mode: ControlMode
|
||||
requested_streams: tuple[str, ...]
|
||||
target_host: str
|
||||
duration_seconds: float
|
||||
duration_seconds: float | None
|
||||
evidence_policy: Literal["required", "best-effort", "disabled"]
|
||||
state: AcquisitionState = "preparing"
|
||||
state_revision: int = 1
|
||||
|
||||
+361
-29
@@ -2,20 +2,21 @@ from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from typing import Annotated, Any, Literal, Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, field_validator, model_validator
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from k1link.compute import RecordedPerceptionOverlayError
|
||||
from k1link.compute import RecordedPerceptionOverlayError, RecordedPerceptionVideo
|
||||
from k1link.sessions import (
|
||||
RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
LayoutConflictError,
|
||||
MaterializedRecording,
|
||||
RecordedMediaFile,
|
||||
@@ -42,6 +43,7 @@ from k1link.viewer.recorded import (
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||||
RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA = "missioncore.observation-recorded-media/v3"
|
||||
SAFE_SOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$")
|
||||
SAFE_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
MAX_SAFE_INTEGER = 9_007_199_254_740_991
|
||||
@@ -103,7 +105,12 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
active_view: Literal["spatial", "perception", "metrics"] = "spatial"
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
view_reset_generation: Literal[0, 1] = 0
|
||||
unified_perception: StrictBool = False
|
||||
show_detections_2d: StrictBool = False
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
|
||||
|
||||
class RecordedPerceptionRequest(StrictApiModel):
|
||||
@@ -254,6 +261,14 @@ class RecordedPerceptionOverlayProvider(Protocol):
|
||||
) -> bytes | None: ...
|
||||
|
||||
|
||||
class RecordedPerceptionMediaProvider(Protocol):
|
||||
def video(
|
||||
self,
|
||||
session_id: str,
|
||||
result_id: str | None = None,
|
||||
) -> RecordedPerceptionVideo | None: ...
|
||||
|
||||
|
||||
def build_session_router(
|
||||
store: SessionStore,
|
||||
*,
|
||||
@@ -263,6 +278,7 @@ def build_session_router(
|
||||
recording_preparation_manager: SessionRecordingPreparationManager | None = None,
|
||||
media_inspector: RecordedMediaInspector | None = None,
|
||||
perception_overlay_provider: RecordedPerceptionOverlayProvider | None = None,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
allow_synchronous_recording_fallback: bool = False,
|
||||
replay_action_id: str = DEFAULT_REPLAY_ACTION_ID,
|
||||
) -> APIRouter:
|
||||
@@ -463,6 +479,7 @@ def build_session_router(
|
||||
reserved.recording,
|
||||
command,
|
||||
reserved.recorded_media or (),
|
||||
perception_media_provider=perception_media_provider,
|
||||
)
|
||||
|
||||
if recording_materializer is not None and allow_synchronous_recording_fallback:
|
||||
@@ -477,7 +494,12 @@ def build_session_router(
|
||||
)
|
||||
except SessionIntegrityError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return _recording_launch_document(recording, command, recorded_media)
|
||||
return _recording_launch_document(
|
||||
recording,
|
||||
command,
|
||||
recorded_media,
|
||||
perception_media_provider=perception_media_provider,
|
||||
)
|
||||
if recording_materializer is not None:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
@@ -544,6 +566,7 @@ def build_session_router(
|
||||
reserved.recording,
|
||||
snapshot.command,
|
||||
snapshot.recorded_media or (),
|
||||
perception_media_provider=perception_media_provider,
|
||||
),
|
||||
headers={
|
||||
"Cache-Control": "no-store",
|
||||
@@ -778,6 +801,11 @@ def build_session_router(
|
||||
application_id=RECORDED_APPLICATION_ID,
|
||||
recording_id=request.recording_id,
|
||||
active_view=request.active_view,
|
||||
view_reset_generation=request.view_reset_generation,
|
||||
unified_perception=request.unified_perception,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -853,6 +881,63 @@ def build_session_router(
|
||||
},
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/manifest"
|
||||
)
|
||||
def get_recorded_perception_media_manifest(
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
if_match: Annotated[str | None, Header(alias="If-Match")] = None,
|
||||
) -> JSONResponse:
|
||||
video = _resolve_recorded_perception_video(
|
||||
store,
|
||||
catalog_refresher,
|
||||
perception_media_provider,
|
||||
session_id,
|
||||
result_id,
|
||||
)
|
||||
etag = f'"sha256:{video.sha256}"'
|
||||
_require_matching_digest(etag, if_match)
|
||||
return JSONResponse(
|
||||
content=_recorded_perception_manifest_document(video),
|
||||
headers={
|
||||
"Cache-Control": "private, no-cache",
|
||||
"ETag": etag,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.api_route(
|
||||
"/api/v1/observation-sessions/{session_id}/perception-media/"
|
||||
"{result_id}/recording.mp4",
|
||||
methods=["GET", "HEAD"],
|
||||
)
|
||||
def stream_recorded_perception_media(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> Response:
|
||||
video = _resolve_recorded_perception_video(
|
||||
store,
|
||||
catalog_refresher,
|
||||
perception_media_provider,
|
||||
session_id,
|
||||
result_id,
|
||||
)
|
||||
if SAFE_SHA256.fullmatch(generation) is None or generation != video.sha256:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение видео сегментации было заменено.",
|
||||
)
|
||||
return _recorded_perception_video_response(
|
||||
video,
|
||||
range_header,
|
||||
head_only=request.method == "HEAD",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/observation-sessions/{session_id}/media/{artifact_id}/manifest")
|
||||
def get_recorded_media_manifest(
|
||||
session_id: str,
|
||||
@@ -944,6 +1029,41 @@ def build_session_router(
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return _recorded_media_file_response(media_file, range_header)
|
||||
|
||||
@router.api_route(
|
||||
"/api/v1/observation-sessions/{session_id}/media/{artifact_id}/"
|
||||
"epochs/{epoch_ordinal}/recording.mp4",
|
||||
methods=["GET", "HEAD"],
|
||||
)
|
||||
def stream_recorded_media_epoch(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
artifact_id: str,
|
||||
epoch_ordinal: int,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
range_header: Annotated[str | None, Header(alias="Range")] = None,
|
||||
) -> Response:
|
||||
manifest = _resolve_recorded_media_manifest(
|
||||
store,
|
||||
recorded_media_inspector,
|
||||
recording_preparation_manager,
|
||||
allow_synchronous_recording_fallback,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
artifact_id,
|
||||
)
|
||||
if SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
|
||||
raise HTTPException(
|
||||
status_code=412,
|
||||
detail="Поколение записанного видео было заменено.",
|
||||
)
|
||||
return _recorded_media_epoch_response(
|
||||
recorded_media_inspector,
|
||||
manifest,
|
||||
epoch_ordinal,
|
||||
range_header,
|
||||
head_only=request.method == "HEAD",
|
||||
)
|
||||
|
||||
@router.get("/api/v1/workspace-layouts/{workspace_id}")
|
||||
def get_workspace_layout(workspace_id: str, response: Response) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -1259,9 +1379,16 @@ def _recording_launch_document(
|
||||
recording: MaterializedRecording,
|
||||
command: ReplayCommand,
|
||||
recorded_media: tuple[RecordedMediaManifest, ...] = (),
|
||||
*,
|
||||
perception_media_provider: RecordedPerceptionMediaProvider | None = None,
|
||||
) -> dict[str, Any]:
|
||||
del perception_media_provider
|
||||
encoded_session_id = quote(recording.session_id, safe="")
|
||||
source_url = f"/api/v1/observation-sessions/{encoded_session_id}/recording.rrd"
|
||||
media_sources = [
|
||||
_recorded_media_launch_source(manifest, index=index)
|
||||
for index, manifest in enumerate(recorded_media, start=1)
|
||||
]
|
||||
return {
|
||||
"schema_version": "missioncore.observation-session-replay/v2",
|
||||
"launch": {
|
||||
@@ -1280,13 +1407,7 @@ def _recording_launch_document(
|
||||
"speed": command.speed,
|
||||
"loop": command.loop,
|
||||
},
|
||||
"media_sources": [
|
||||
_recorded_media_launch_source(
|
||||
manifest,
|
||||
index=index,
|
||||
)
|
||||
for index, manifest in enumerate(recorded_media, start=1)
|
||||
],
|
||||
"media_sources": media_sources,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1334,6 +1455,137 @@ def _recorded_media_launch_source(
|
||||
}
|
||||
|
||||
|
||||
def _recorded_perception_launch_source(video: RecordedPerceptionVideo) -> dict[str, Any]:
|
||||
encoded_session_id = quote(video.session_id, safe="")
|
||||
encoded_result_id = quote(video.result_id, safe="")
|
||||
return {
|
||||
"id": video.public_source_id,
|
||||
"label": video.label,
|
||||
"modality": "video",
|
||||
"manifest_url": (
|
||||
f"/api/v1/observation-sessions/{encoded_session_id}/perception-media/"
|
||||
f"{encoded_result_id}/manifest"
|
||||
),
|
||||
"media_type": "video/mp4",
|
||||
"manifest_generation_sha256": video.sha256,
|
||||
"byte_length": video.byte_length,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"seekable": True,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_recorded_perception_video(
|
||||
store: SessionStore,
|
||||
catalog_refresher: CatalogRefresher | None,
|
||||
provider: RecordedPerceptionMediaProvider | None,
|
||||
session_id: str,
|
||||
result_id: str,
|
||||
) -> RecordedPerceptionVideo:
|
||||
if provider is None:
|
||||
raise HTTPException(status_code=404, detail="Видео сегментации не найдено.")
|
||||
try:
|
||||
_prepare_replay(
|
||||
store,
|
||||
catalog_refresher,
|
||||
session_id,
|
||||
1.0,
|
||||
False,
|
||||
False,
|
||||
)
|
||||
video = provider.video(session_id, result_id)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Некорректный идентификатор результата сегментации.",
|
||||
) from exc
|
||||
if video is None:
|
||||
raise HTTPException(status_code=404, detail="Видео сегментации не найдено.")
|
||||
return video
|
||||
|
||||
|
||||
def _recorded_perception_manifest_document(
|
||||
video: RecordedPerceptionVideo,
|
||||
) -> dict[str, Any]:
|
||||
encoded_session_id = quote(video.session_id, safe="")
|
||||
encoded_result_id = quote(video.result_id, safe="")
|
||||
base = (
|
||||
f"/api/v1/observation-sessions/{encoded_session_id}/perception-media/"
|
||||
f"{encoded_result_id}"
|
||||
)
|
||||
return {
|
||||
"schema_version": RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA,
|
||||
"source_id": video.public_source_id,
|
||||
"generation_sha256": video.sha256,
|
||||
"byte_length": video.byte_length,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
"epochs": [
|
||||
{
|
||||
"ordinal": 1,
|
||||
"timeline_start_seconds": video.timeline_start_seconds,
|
||||
"timeline_end_seconds": video.timeline_end_seconds,
|
||||
"media_type": video.media_type,
|
||||
"byte_length": video.byte_length,
|
||||
"stream_url": f"{base}/recording.mp4?generation={video.sha256}",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _recorded_perception_video_response(
|
||||
video: RecordedPerceptionVideo,
|
||||
range_header: str | None,
|
||||
*,
|
||||
head_only: bool,
|
||||
) -> Response:
|
||||
start, end = 0, video.byte_length - 1
|
||||
status_code = 200
|
||||
if range_header is not None:
|
||||
start, end = _parse_byte_range(range_header, video.byte_length)
|
||||
status_code = 206
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||||
"ETag": f'"sha256:{video.sha256}"',
|
||||
"Content-Length": str(end - start + 1),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": 'inline; filename="recorded-perception.mp4"',
|
||||
}
|
||||
if status_code == 206:
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{video.byte_length}"
|
||||
if head_only:
|
||||
return Response(
|
||||
status_code=status_code,
|
||||
media_type=video.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
return StreamingResponse(
|
||||
_iter_regular_file_range(video.path, start, end),
|
||||
status_code=status_code,
|
||||
media_type=video.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _iter_regular_file_range(path: Path, start: int, end: int) -> Iterator[bytes]:
|
||||
remaining = end - start + 1
|
||||
with path.open("rb") as stream:
|
||||
stream.seek(start)
|
||||
while remaining:
|
||||
chunk = stream.read(min(1024 * 1024, remaining))
|
||||
if not chunk:
|
||||
raise SessionIntegrityError("recorded perception video ended unexpectedly")
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
|
||||
def _recorded_media_manifest_document(
|
||||
manifest: RecordedMediaManifest,
|
||||
) -> dict[str, Any]:
|
||||
@@ -1341,7 +1593,7 @@ def _recorded_media_manifest_document(
|
||||
encoded_artifact_id = quote(manifest.artifact_id, safe="")
|
||||
base = f"/api/v1/observation-sessions/{encoded_session_id}/media/{encoded_artifact_id}"
|
||||
return {
|
||||
"schema_version": RECORDED_MEDIA_MANIFEST_SCHEMA,
|
||||
"schema_version": RECORDED_MEDIA_STREAM_MANIFEST_SCHEMA,
|
||||
"source_id": manifest.public_source_id,
|
||||
"generation_sha256": manifest.generation_sha256,
|
||||
"byte_length": manifest.byte_length,
|
||||
@@ -1354,20 +1606,12 @@ def _recorded_media_manifest_document(
|
||||
"timeline_start_seconds": epoch.timeline_start_seconds,
|
||||
"timeline_end_seconds": epoch.timeline_end_seconds,
|
||||
"media_type": epoch.media_type,
|
||||
"init_url": f"{base}/epochs/{epoch.ordinal}/init.mp4",
|
||||
"init_byte_length": epoch.init_byte_length,
|
||||
"init_sha256": epoch.init_sha256,
|
||||
"segment_count": len(epoch.segments),
|
||||
"segment_url_prefix": f"{base}/epochs/{epoch.ordinal}/segments/",
|
||||
"segments": [
|
||||
{
|
||||
"sequence": segment.sequence,
|
||||
"url": (f"{base}/epochs/{epoch.ordinal}/segments/{segment.sequence}.m4s"),
|
||||
"byte_length": segment.byte_length,
|
||||
"sha256": segment.sha256,
|
||||
}
|
||||
for segment in epoch.segments
|
||||
],
|
||||
"byte_length": epoch.init_byte_length
|
||||
+ sum(segment.byte_length for segment in epoch.segments),
|
||||
"stream_url": (
|
||||
f"{base}/epochs/{epoch.ordinal}/recording.mp4"
|
||||
f"?generation={manifest.generation_sha256}"
|
||||
),
|
||||
}
|
||||
for epoch in manifest.epochs
|
||||
],
|
||||
@@ -1500,6 +1744,94 @@ def _recorded_media_file_response(
|
||||
)
|
||||
|
||||
|
||||
def _recorded_media_epoch_response(
|
||||
inspector: RecordedMediaInspector,
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch_ordinal: int,
|
||||
range_header: str | None,
|
||||
*,
|
||||
head_only: bool,
|
||||
) -> Response:
|
||||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||||
if len(matches) != 1:
|
||||
raise HTTPException(status_code=404, detail="Эпоха записанного медиаканала не найдена.")
|
||||
epoch = matches[0]
|
||||
byte_length = epoch.init_byte_length + sum(
|
||||
segment.byte_length for segment in epoch.segments
|
||||
)
|
||||
start, end = (0, byte_length - 1)
|
||||
status_code = 200
|
||||
if range_header is not None:
|
||||
start, end = _parse_byte_range(range_header, byte_length)
|
||||
status_code = 206
|
||||
|
||||
etag = f'"generation:{manifest.generation_sha256}:epoch:{epoch.ordinal}"'
|
||||
headers = {
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "private, max-age=31536000, immutable, no-transform",
|
||||
"ETag": etag,
|
||||
"Content-Length": str(end - start + 1),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": (
|
||||
f'inline; filename="recorded-camera-{epoch.ordinal}.mp4"'
|
||||
),
|
||||
}
|
||||
if status_code == 206:
|
||||
headers["Content-Range"] = f"bytes {start}-{end}/{byte_length}"
|
||||
if head_only:
|
||||
return Response(
|
||||
status_code=status_code,
|
||||
media_type=epoch.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
return StreamingResponse(
|
||||
_iter_recorded_media_epoch_range(
|
||||
inspector,
|
||||
manifest,
|
||||
epoch.ordinal,
|
||||
start,
|
||||
end,
|
||||
),
|
||||
status_code=status_code,
|
||||
media_type=epoch.media_type,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
|
||||
def _iter_recorded_media_epoch_range(
|
||||
inspector: RecordedMediaInspector,
|
||||
manifest: RecordedMediaManifest,
|
||||
epoch_ordinal: int,
|
||||
start: int,
|
||||
end: int,
|
||||
) -> Iterator[bytes]:
|
||||
matches = tuple(epoch for epoch in manifest.epochs if epoch.ordinal == epoch_ordinal)
|
||||
if len(matches) != 1:
|
||||
raise SessionIntegrityError("recorded media epoch is unavailable")
|
||||
epoch = matches[0]
|
||||
offset = 0
|
||||
parts = chain(
|
||||
((epoch.init_byte_length, None),),
|
||||
((segment.byte_length, segment.sequence) for segment in epoch.segments),
|
||||
)
|
||||
for part_length, sequence in parts:
|
||||
part_end = offset + part_length - 1
|
||||
if part_end < start:
|
||||
offset += part_length
|
||||
continue
|
||||
if offset > end:
|
||||
break
|
||||
media_file = (
|
||||
inspector.get_init(manifest, epoch_ordinal)
|
||||
if sequence is None
|
||||
else inspector.get_segment(manifest, epoch_ordinal, sequence)
|
||||
)
|
||||
local_start = max(0, start - offset)
|
||||
local_end = min(part_length - 1, end - offset)
|
||||
yield media_file.payload[local_start : local_end + 1]
|
||||
offset += part_length
|
||||
|
||||
|
||||
def _parse_byte_range(value: str, byte_length: int) -> tuple[int, int]:
|
||||
match = re.fullmatch(r"bytes=(\d*)-(\d*)", value.strip())
|
||||
if match is None or byte_length < 1:
|
||||
|
||||
Reference in New Issue
Block a user