feat(perception): mine native fisheye risk cases
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"schema_version": "missioncore.m48q-native-risk-case-mining-profile/v1",
|
||||
"profile_id": "m48q-ravnoves00-native-raw-kb4-risk-review/v1",
|
||||
"question": "Which real RAVNOVES00 raw-fisheye frames expose the native RF-DETR risk semantics that require operator review before independent route truth exists?",
|
||||
"source": {
|
||||
"source_id": "RAVNOVES00",
|
||||
"frame_count": 4489,
|
||||
"raster_width": 800,
|
||||
"raster_height": 600,
|
||||
"video_sha256": "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||
"geometric_resampling": false,
|
||||
"rectification": false,
|
||||
"warp": false
|
||||
},
|
||||
"candidate": {
|
||||
"provider_id": "triton-rf-detr-large-coco-native-kb4-risk-fp16-shadow/v0",
|
||||
"model_id": "rf_detr_large_native_kb4:1",
|
||||
"preprocess_id": "raw-kb4-uint8-fused-mask-rgb-pad8-imagenet-trt/v0",
|
||||
"engine_sha256": "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695",
|
||||
"minimum_score": 0.25
|
||||
},
|
||||
"risk_families": {
|
||||
"person": ["person"],
|
||||
"animal": ["bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe"],
|
||||
"light-road-user": ["bicycle", "motorcycle", "skateboard"],
|
||||
"vehicle": ["car", "bus", "truck"]
|
||||
},
|
||||
"selection": {
|
||||
"case_count": 24,
|
||||
"minimum_sequence_separation": 12,
|
||||
"low_confidence_maximum_score": 0.4,
|
||||
"fisheye_edge_margin_fraction": 0.12,
|
||||
"bucket_quotas": {
|
||||
"person": 3,
|
||||
"animal": 3,
|
||||
"light-road-user": 3,
|
||||
"vehicle": 3,
|
||||
"low-confidence": 3,
|
||||
"fisheye-edge": 3,
|
||||
"native-fewer-than-legacy": 3,
|
||||
"native-more-than-legacy": 3
|
||||
}
|
||||
},
|
||||
"scope": {
|
||||
"quality_evaluated": false,
|
||||
"ground_truth": false,
|
||||
"candidate_accepted": false,
|
||||
"production_accepted": false,
|
||||
"purpose": "bounded-native-risk-case-review"
|
||||
},
|
||||
"authority": {
|
||||
"ground_truth": false,
|
||||
"candidate_accepted": false,
|
||||
"commands_enabled": false,
|
||||
"actuation_allowed": false,
|
||||
"navigation_or_safety_accepted": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,677 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mine bounded native raw-fisheye risk cases from immutable Worker 006 ledgers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Final
|
||||
|
||||
PROFILE_SCHEMA: Final = "missioncore.m48q-native-risk-case-mining-profile/v1"
|
||||
RESULT_SCHEMA: Final = "missioncore.m48q-native-risk-case-mining-result/v1"
|
||||
CASE_SCHEMA: Final = "missioncore.m48q-native-risk-review-case/v1"
|
||||
GRAPH_FRAME_SCHEMA: Final = "missioncore.m48s-reference-graph-frame-evidence/v1"
|
||||
COMPARISON_FRAME_SCHEMA: Final = "missioncore.m48n-native-vs-704-frame/v0"
|
||||
FALSE_AUTHORITY: Final = {
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
}
|
||||
|
||||
|
||||
class M48QCaseMiningError(RuntimeError):
|
||||
"""Raised when immutable native review evidence is inconsistent."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Proposal:
|
||||
proposal_id: str
|
||||
class_name: str
|
||||
risk_family: str
|
||||
score: float
|
||||
box_xyxy: tuple[float, float, float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FrameCandidate:
|
||||
sequence: int
|
||||
frame_id: str
|
||||
evidence_time_ns: int
|
||||
proposals: tuple[Proposal, ...]
|
||||
native_count: int
|
||||
legacy_count: int
|
||||
matched_count: int
|
||||
buckets: frozenset[str]
|
||||
|
||||
|
||||
def canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_path(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_object(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
value: object = json.loads(path.read_text("utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise M48QCaseMiningError(f"cannot read JSON object: {path.name}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise M48QCaseMiningError(f"JSON evidence is not an object: {path.name}")
|
||||
return value
|
||||
|
||||
|
||||
def _verify_hash(path: Path, expected: str, label: str) -> str:
|
||||
actual = sha256_path(path)
|
||||
if actual != expected:
|
||||
raise M48QCaseMiningError(f"{label} SHA-256 changed: {actual}")
|
||||
return actual
|
||||
|
||||
|
||||
def _risk_family_map(profile: Mapping[str, Any]) -> dict[str, str]:
|
||||
raw = profile.get("risk_families")
|
||||
if not isinstance(raw, dict):
|
||||
raise M48QCaseMiningError("risk family profile is invalid")
|
||||
result: dict[str, str] = {}
|
||||
for family, classes in raw.items():
|
||||
if (
|
||||
family not in {"person", "animal", "light-road-user", "vehicle"}
|
||||
or not isinstance(classes, list)
|
||||
or not classes
|
||||
):
|
||||
raise M48QCaseMiningError("risk family profile changed")
|
||||
for class_name in classes:
|
||||
if not isinstance(class_name, str) or class_name in result:
|
||||
raise M48QCaseMiningError("risk classes are invalid or duplicated")
|
||||
result[class_name] = family
|
||||
return result
|
||||
|
||||
|
||||
def _proposal(
|
||||
value: object,
|
||||
*,
|
||||
sequence: int,
|
||||
profile: Mapping[str, Any],
|
||||
families: Mapping[str, str],
|
||||
) -> Proposal:
|
||||
if not isinstance(value, dict):
|
||||
raise M48QCaseMiningError("detector proposal is not an object")
|
||||
candidate = profile["candidate"]
|
||||
expected_frame_id = f"frame-{sequence:06d}"
|
||||
if (
|
||||
value.get("frame_id") != expected_frame_id
|
||||
or value.get("provider_id") != candidate["provider_id"]
|
||||
or value.get("model_id") != candidate["model_id"]
|
||||
or value.get("preprocess_id") != candidate["preprocess_id"]
|
||||
):
|
||||
raise M48QCaseMiningError("native detector proposal identity changed")
|
||||
proposal_id = value.get("proposal_id")
|
||||
class_name = value.get("semantic_hint")
|
||||
score = value.get("objectness")
|
||||
region = value.get("region")
|
||||
if (
|
||||
not isinstance(proposal_id, str)
|
||||
or not isinstance(class_name, str)
|
||||
or class_name not in families
|
||||
or not isinstance(score, (float, int))
|
||||
or isinstance(score, bool)
|
||||
or not math.isfinite(float(score))
|
||||
or float(score) < candidate["minimum_score"]
|
||||
or not isinstance(region, dict)
|
||||
):
|
||||
raise M48QCaseMiningError("native detector proposal contract changed")
|
||||
coordinates = tuple(region.get(key) for key in ("x_min", "y_min", "x_max", "y_max"))
|
||||
if not all(
|
||||
isinstance(item, (float, int)) and not isinstance(item, bool) and math.isfinite(float(item))
|
||||
for item in coordinates
|
||||
):
|
||||
raise M48QCaseMiningError("native detector box is invalid")
|
||||
left, top, right, bottom = (float(item) for item in coordinates)
|
||||
source = profile["source"]
|
||||
if not (
|
||||
0 <= left < right <= source["raster_width"] and 0 <= top < bottom <= source["raster_height"]
|
||||
):
|
||||
raise M48QCaseMiningError("native detector box escaped the raw source raster")
|
||||
return Proposal(
|
||||
proposal_id=proposal_id,
|
||||
class_name=class_name,
|
||||
risk_family=families[class_name],
|
||||
score=float(score),
|
||||
box_xyxy=(left, top, right, bottom),
|
||||
)
|
||||
|
||||
|
||||
def load_comparison_rows(path: Path, expected_count: int) -> dict[int, tuple[int, int, int]]:
|
||||
rows: dict[int, tuple[int, int, int]] = {}
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for line_number, line in enumerate(stream, start=1):
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict):
|
||||
raise M48QCaseMiningError("comparison frame is not an object")
|
||||
if value.get("schema_version") not in {None, COMPARISON_FRAME_SCHEMA}:
|
||||
raise M48QCaseMiningError("comparison frame schema changed")
|
||||
sequence = value.get("frame_index")
|
||||
counts = tuple(
|
||||
value.get(key)
|
||||
for key in (
|
||||
"native_detection_count",
|
||||
"baseline_detection_count",
|
||||
"matched_detection_count_iou_at_least_0_5",
|
||||
)
|
||||
)
|
||||
if (
|
||||
not isinstance(sequence, int)
|
||||
or isinstance(sequence, bool)
|
||||
or sequence in rows
|
||||
or not all(
|
||||
isinstance(item, int) and not isinstance(item, bool) and item >= 0
|
||||
for item in counts
|
||||
)
|
||||
):
|
||||
raise M48QCaseMiningError(f"comparison frame {line_number} is invalid")
|
||||
rows[sequence] = counts
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise M48QCaseMiningError("comparison frame ledger cannot be read") from exc
|
||||
if set(rows) != set(range(expected_count)):
|
||||
raise M48QCaseMiningError("comparison frame ledger is incomplete")
|
||||
return rows
|
||||
|
||||
|
||||
def _case_buckets(
|
||||
proposals: Sequence[Proposal],
|
||||
*,
|
||||
native_count: int,
|
||||
legacy_count: int,
|
||||
profile: Mapping[str, Any],
|
||||
) -> frozenset[str]:
|
||||
selection = profile["selection"]
|
||||
buckets = {item.risk_family for item in proposals}
|
||||
if any(item.score <= selection["low_confidence_maximum_score"] for item in proposals):
|
||||
buckets.add("low-confidence")
|
||||
width = profile["source"]["raster_width"]
|
||||
height = profile["source"]["raster_height"]
|
||||
margin = selection["fisheye_edge_margin_fraction"]
|
||||
if any(
|
||||
item.box_xyxy[0] <= width * margin
|
||||
or item.box_xyxy[2] >= width * (1 - margin)
|
||||
or item.box_xyxy[1] <= height * margin
|
||||
or item.box_xyxy[3] >= height * (1 - margin)
|
||||
for item in proposals
|
||||
):
|
||||
buckets.add("fisheye-edge")
|
||||
if native_count < legacy_count:
|
||||
buckets.add("native-fewer-than-legacy")
|
||||
elif native_count > legacy_count:
|
||||
buckets.add("native-more-than-legacy")
|
||||
return frozenset(buckets)
|
||||
|
||||
|
||||
def load_graph_candidates(
|
||||
path: Path,
|
||||
*,
|
||||
profile: Mapping[str, Any],
|
||||
comparisons: Mapping[int, tuple[int, int, int]],
|
||||
) -> list[FrameCandidate]:
|
||||
families = _risk_family_map(profile)
|
||||
expected_count = profile["source"]["frame_count"]
|
||||
candidates: list[FrameCandidate] = []
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as stream:
|
||||
for sequence, line in enumerate(stream):
|
||||
value = json.loads(line)
|
||||
if not isinstance(value, dict) or value.get("schema_version") != GRAPH_FRAME_SCHEMA:
|
||||
raise M48QCaseMiningError("graph frame schema changed")
|
||||
source = value.get("source_envelope")
|
||||
raw_proposals = value.get("detector_proposals")
|
||||
if (
|
||||
not isinstance(source, dict)
|
||||
or source.get("sequence") != sequence
|
||||
or source.get("frame_id") != f"frame-{sequence:06d}"
|
||||
or not isinstance(raw_proposals, list)
|
||||
or value.get("authority") != FALSE_AUTHORITY
|
||||
):
|
||||
raise M48QCaseMiningError("graph frame identity or authority changed")
|
||||
timestamps = source.get("timestamps")
|
||||
evidence_time_ns = (
|
||||
timestamps.get("source_ns") if isinstance(timestamps, dict) else None
|
||||
)
|
||||
if (
|
||||
not isinstance(evidence_time_ns, int)
|
||||
or isinstance(evidence_time_ns, bool)
|
||||
or evidence_time_ns < 0
|
||||
):
|
||||
raise M48QCaseMiningError("graph frame evidence time is invalid")
|
||||
proposals = tuple(
|
||||
_proposal(item, sequence=sequence, profile=profile, families=families)
|
||||
for item in raw_proposals
|
||||
)
|
||||
native_count, legacy_count, matched_count = comparisons[sequence]
|
||||
if len(proposals) != native_count:
|
||||
raise M48QCaseMiningError("native graph/comparison detector count changed")
|
||||
buckets = _case_buckets(
|
||||
proposals,
|
||||
native_count=native_count,
|
||||
legacy_count=legacy_count,
|
||||
profile=profile,
|
||||
)
|
||||
candidates.append(
|
||||
FrameCandidate(
|
||||
sequence=sequence,
|
||||
frame_id=source["frame_id"],
|
||||
evidence_time_ns=evidence_time_ns,
|
||||
proposals=proposals,
|
||||
native_count=native_count,
|
||||
legacy_count=legacy_count,
|
||||
matched_count=matched_count,
|
||||
buckets=buckets,
|
||||
)
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise M48QCaseMiningError("graph frame ledger cannot be read") from exc
|
||||
if len(candidates) != expected_count:
|
||||
raise M48QCaseMiningError("graph frame ledger is incomplete")
|
||||
return candidates
|
||||
|
||||
|
||||
def _quantile_order(items: Sequence[FrameCandidate]) -> Iterable[FrameCandidate]:
|
||||
if not items:
|
||||
return ()
|
||||
indexes: list[int] = []
|
||||
left = 0
|
||||
right = len(items) - 1
|
||||
while left <= right:
|
||||
middle = (left + right) // 2
|
||||
indexes.append(middle)
|
||||
if middle - left > 0:
|
||||
indexes.append((left + middle - 1) // 2)
|
||||
if right - middle > 0:
|
||||
indexes.append((middle + 1 + right) // 2)
|
||||
left += 1
|
||||
right -= 1
|
||||
seen: set[int] = set()
|
||||
return (items[index] for index in indexes if not (index in seen or seen.add(index)))
|
||||
|
||||
|
||||
def select_cases(
|
||||
candidates: Sequence[FrameCandidate],
|
||||
*,
|
||||
bucket_quotas: Mapping[str, int],
|
||||
minimum_sequence_separation: int,
|
||||
) -> list[FrameCandidate]:
|
||||
selected: list[FrameCandidate] = []
|
||||
selected_sequences: set[int] = set()
|
||||
for bucket, quota in bucket_quotas.items():
|
||||
eligible = [item for item in candidates if bucket in item.buckets]
|
||||
admitted = 0
|
||||
for item in _quantile_order(eligible):
|
||||
if item.sequence in selected_sequences:
|
||||
continue
|
||||
if any(
|
||||
abs(item.sequence - prior.sequence) < minimum_sequence_separation
|
||||
for prior in selected
|
||||
):
|
||||
continue
|
||||
selected.append(item)
|
||||
selected_sequences.add(item.sequence)
|
||||
admitted += 1
|
||||
if admitted == quota:
|
||||
break
|
||||
if admitted != quota:
|
||||
raise M48QCaseMiningError(
|
||||
f"selection bucket {bucket} produced {admitted}/{quota} separated cases"
|
||||
)
|
||||
expected = sum(bucket_quotas.values())
|
||||
if len(selected) != expected:
|
||||
raise M48QCaseMiningError("selected case count changed")
|
||||
return sorted(selected, key=lambda item: item.sequence)
|
||||
|
||||
|
||||
def render_selected_frames(
|
||||
*,
|
||||
video_path: Path,
|
||||
selected: Sequence[FrameCandidate],
|
||||
cases_root: Path,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> dict[int, dict[str, object]]:
|
||||
import av
|
||||
from av.error import FFmpegError
|
||||
from PIL import Image
|
||||
|
||||
wanted = {item.sequence for item in selected}
|
||||
rendered: dict[int, dict[str, object]] = {}
|
||||
cases_root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
try:
|
||||
with av.open(str(video_path), mode="r") as container:
|
||||
streams = list(container.streams.video)
|
||||
if len(streams) != 1:
|
||||
raise M48QCaseMiningError("RAVNOVES00 video stream contract changed")
|
||||
for sequence, frame in enumerate(container.decode(streams[0])):
|
||||
if sequence not in wanted:
|
||||
continue
|
||||
array = frame.to_ndarray(format="rgb24")
|
||||
if array.shape != (height, width, 3):
|
||||
raise M48QCaseMiningError("decoded raw-fisheye raster changed")
|
||||
name = f"frame-{sequence:06d}.jpg"
|
||||
path = cases_root / name
|
||||
Image.fromarray(array, mode="RGB").save(
|
||||
path,
|
||||
format="JPEG",
|
||||
quality=94,
|
||||
subsampling=0,
|
||||
optimize=False,
|
||||
)
|
||||
rendered[sequence] = {
|
||||
"path": f"cases/{name}",
|
||||
"media_type": "image/jpeg",
|
||||
"width": width,
|
||||
"height": height,
|
||||
"byte_length": path.stat().st_size,
|
||||
"sha256": sha256_path(path),
|
||||
"geometric_resampling": False,
|
||||
}
|
||||
if len(rendered) == len(wanted):
|
||||
break
|
||||
except (FFmpegError, OSError) as exc:
|
||||
raise M48QCaseMiningError("RAVNOVES00 raw-fisheye frames cannot be decoded") from exc
|
||||
if set(rendered) != wanted:
|
||||
raise M48QCaseMiningError("not every selected raw-fisheye frame was decoded")
|
||||
return rendered
|
||||
|
||||
|
||||
def _case_document(
|
||||
candidate: FrameCandidate,
|
||||
*,
|
||||
image: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": CASE_SCHEMA,
|
||||
"case_id": f"{candidate.sequence:06d}",
|
||||
"sequence": candidate.sequence,
|
||||
"frame_id": candidate.frame_id,
|
||||
"evidence_time_ns": candidate.evidence_time_ns,
|
||||
"selection_buckets": sorted(candidate.buckets),
|
||||
"comparison": {
|
||||
"native_detection_count": candidate.native_count,
|
||||
"legacy_704_detection_count": candidate.legacy_count,
|
||||
"matched_detection_count_iou_at_least_0_5": candidate.matched_count,
|
||||
"quality_interpretation": "diagnostic-only",
|
||||
},
|
||||
"image": dict(image),
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_id": item.proposal_id,
|
||||
"class_name": item.class_name,
|
||||
"risk_family": item.risk_family,
|
||||
"score": round(item.score, 9),
|
||||
"box_xyxy": [round(value, 6) for value in item.box_xyxy],
|
||||
}
|
||||
for item in candidate.proposals
|
||||
],
|
||||
"ground_truth": False,
|
||||
"quality_evaluated": False,
|
||||
"authority": dict(FALSE_AUTHORITY),
|
||||
}
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> dict[str, object]:
|
||||
for path in (
|
||||
args.profile,
|
||||
args.graph_result,
|
||||
args.graph_frames,
|
||||
args.comparison_result,
|
||||
args.comparison_frames,
|
||||
args.video,
|
||||
):
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise M48QCaseMiningError(f"required evidence is missing: {path.name}")
|
||||
profile_sha256 = _verify_hash(args.profile, args.expected_profile_sha256, "profile")
|
||||
graph_result_sha256 = _verify_hash(
|
||||
args.graph_result, args.expected_graph_result_sha256, "graph result"
|
||||
)
|
||||
graph_frames_sha256 = _verify_hash(
|
||||
args.graph_frames, args.expected_graph_frames_sha256, "graph frames"
|
||||
)
|
||||
comparison_result_sha256 = _verify_hash(
|
||||
args.comparison_result,
|
||||
args.expected_comparison_result_sha256,
|
||||
"comparison result",
|
||||
)
|
||||
comparison_frames_sha256 = _verify_hash(
|
||||
args.comparison_frames,
|
||||
args.expected_comparison_frames_sha256,
|
||||
"comparison frames",
|
||||
)
|
||||
video_sha256 = _verify_hash(args.video, args.expected_video_sha256, "video")
|
||||
runner_sha256 = _verify_hash(Path(__file__), args.expected_runner_sha256, "runner")
|
||||
|
||||
profile = _read_object(args.profile)
|
||||
graph_result = _read_object(args.graph_result)
|
||||
comparison_result = _read_object(args.comparison_result)
|
||||
source = profile.get("source")
|
||||
candidate = profile.get("candidate")
|
||||
selection = profile.get("selection")
|
||||
scope = profile.get("scope")
|
||||
if (
|
||||
profile.get("schema_version") != PROFILE_SCHEMA
|
||||
or not isinstance(source, dict)
|
||||
or source.get("video_sha256") != video_sha256
|
||||
or source.get("raster_width") != 800
|
||||
or source.get("raster_height") != 600
|
||||
or source.get("frame_count") != 4489
|
||||
or source.get("geometric_resampling") is not False
|
||||
or source.get("rectification") is not False
|
||||
or source.get("warp") is not False
|
||||
or not isinstance(candidate, dict)
|
||||
or candidate.get("engine_sha256")
|
||||
!= "b8a40b3580edff001ec9680de68707242294ff590ab296000fae371f1083f695"
|
||||
or not isinstance(selection, dict)
|
||||
or selection.get("case_count") != 24
|
||||
or not isinstance(selection.get("bucket_quotas"), dict)
|
||||
or sum(selection["bucket_quotas"].values()) != 24
|
||||
or scope
|
||||
!= {
|
||||
"quality_evaluated": False,
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"production_accepted": False,
|
||||
"purpose": "bounded-native-risk-case-review",
|
||||
}
|
||||
or profile.get("authority") != FALSE_AUTHORITY
|
||||
):
|
||||
raise M48QCaseMiningError("M4.8Q profile contract changed")
|
||||
if (
|
||||
graph_result.get("schema_version") != "missioncore.m48s-reference-graph-shadow-load/v5"
|
||||
or graph_result.get("completed") is not True
|
||||
or graph_result.get("identity", {}).get("detector_provider_id") != candidate["provider_id"]
|
||||
or graph_result.get("identity", {}).get("inputs", {}).get("video") != video_sha256
|
||||
or graph_result.get("execution", {}).get("frame_evidence", {}).get("sha256")
|
||||
!= graph_frames_sha256
|
||||
or graph_result.get("authority") != FALSE_AUTHORITY
|
||||
or graph_result.get("production_accepted") is not False
|
||||
):
|
||||
raise M48QCaseMiningError("native graph result contract changed")
|
||||
if (
|
||||
comparison_result.get("schema_version") != "missioncore.m48n-native-vs-704-ravnoves00/v0"
|
||||
or comparison_result.get("completed") is not True
|
||||
or comparison_result.get("source", {}).get("video_sha256") != video_sha256
|
||||
or comparison_result.get("source", {}).get("frame_count") != source["frame_count"]
|
||||
or comparison_result.get("execution", {}).get("frames_sha256") != comparison_frames_sha256
|
||||
or comparison_result.get("authority") != FALSE_AUTHORITY
|
||||
):
|
||||
raise M48QCaseMiningError("native/legacy comparison result contract changed")
|
||||
|
||||
output_root = args.output_root
|
||||
if output_root.exists():
|
||||
raise M48QCaseMiningError("M4.8Q output root already exists")
|
||||
output_root.mkdir(mode=0o700, parents=True)
|
||||
comparisons = load_comparison_rows(args.comparison_frames, source["frame_count"])
|
||||
candidates = load_graph_candidates(
|
||||
args.graph_frames,
|
||||
profile=profile,
|
||||
comparisons=comparisons,
|
||||
)
|
||||
selected = select_cases(
|
||||
candidates,
|
||||
bucket_quotas=selection["bucket_quotas"],
|
||||
minimum_sequence_separation=selection["minimum_sequence_separation"],
|
||||
)
|
||||
rendered = render_selected_frames(
|
||||
video_path=args.video,
|
||||
selected=selected,
|
||||
cases_root=output_root / "cases",
|
||||
width=source["raster_width"],
|
||||
height=source["raster_height"],
|
||||
)
|
||||
cases = [
|
||||
_case_document(
|
||||
item,
|
||||
image=rendered[item.sequence],
|
||||
)
|
||||
for item in selected
|
||||
]
|
||||
cases_path = output_root / "cases.jsonl"
|
||||
with cases_path.open("xb") as stream:
|
||||
for case in cases:
|
||||
stream.write(canonical_json(case) + b"\n")
|
||||
|
||||
class_counts = Counter(proposal.class_name for item in selected for proposal in item.proposals)
|
||||
selected_bucket_coverage = Counter(bucket for item in selected for bucket in item.buckets)
|
||||
identity = {
|
||||
"schema_version": RESULT_SCHEMA,
|
||||
"profile": {"profile_id": profile["profile_id"], "sha256": profile_sha256},
|
||||
"source": {
|
||||
"source_id": source["source_id"],
|
||||
"video_sha256": video_sha256,
|
||||
"graph_result_sha256": graph_result_sha256,
|
||||
"graph_frames_sha256": graph_frames_sha256,
|
||||
"comparison_result_sha256": comparison_result_sha256,
|
||||
"comparison_frames_sha256": comparison_frames_sha256,
|
||||
},
|
||||
"candidate": dict(candidate),
|
||||
"runner_sha256": runner_sha256,
|
||||
"cases_sha256": sha256_path(cases_path),
|
||||
"authority": dict(FALSE_AUTHORITY),
|
||||
}
|
||||
result: dict[str, object] = {
|
||||
"schema_version": RESULT_SCHEMA,
|
||||
"status": "complete-review-ready-quality-not-adjudicated",
|
||||
"completed": True,
|
||||
"worker": {"worker_id": "worker-006", "node": "DESKTOP-OPJ8J04"},
|
||||
"identity": identity,
|
||||
"report_identity_sha256": hashlib.sha256(canonical_json(identity)).hexdigest(),
|
||||
"source": {
|
||||
**dict(source),
|
||||
"graph_result_sha256": graph_result_sha256,
|
||||
"graph_frames_sha256": graph_frames_sha256,
|
||||
"comparison_result_sha256": comparison_result_sha256,
|
||||
"comparison_frames_sha256": comparison_frames_sha256,
|
||||
},
|
||||
"candidate": dict(candidate),
|
||||
"selection": {
|
||||
"case_count": len(cases),
|
||||
"minimum_sequence_separation": selection["minimum_sequence_separation"],
|
||||
"configured_bucket_quotas": selection["bucket_quotas"],
|
||||
"selected_bucket_coverage": dict(sorted(selected_bucket_coverage.items())),
|
||||
"selected_sequences": [item.sequence for item in selected],
|
||||
"selected_class_counts": dict(sorted(class_counts.items())),
|
||||
},
|
||||
"artifacts": {
|
||||
"cases": {
|
||||
"path": "cases.jsonl",
|
||||
"schema_version": CASE_SCHEMA,
|
||||
"row_count": len(cases),
|
||||
"sha256": sha256_path(cases_path),
|
||||
},
|
||||
"images": {
|
||||
"root": "cases",
|
||||
"count": len(rendered),
|
||||
"geometric_resampling": False,
|
||||
},
|
||||
},
|
||||
"decision": {
|
||||
"quality_evaluated": False,
|
||||
"ground_truth": False,
|
||||
"candidate_accepted": False,
|
||||
"production_accepted": False,
|
||||
"next_action": "operator-adjudication-in-existing-m48-review-instrument",
|
||||
},
|
||||
"limitations": [
|
||||
"The selected RAVNOVES00 cases are diagnostic samples, not independent truth.",
|
||||
(
|
||||
"Native-versus-legacy detection-count differences are not quality "
|
||||
"verdicts because legacy 704 stretches the raw 4:3 raster."
|
||||
),
|
||||
(
|
||||
"No child/adult, behavior, physical track identity, navigation or "
|
||||
"actuation claim is made."
|
||||
),
|
||||
(
|
||||
"JPEG review derivatives preserve the 800x600 raster without geometric "
|
||||
"resampling but are not lossless source frames."
|
||||
),
|
||||
],
|
||||
"authority": dict(FALSE_AUTHORITY),
|
||||
}
|
||||
(output_root / "result.json").write_bytes(canonical_json(result) + b"\n")
|
||||
return result
|
||||
|
||||
|
||||
def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
for name in (
|
||||
"profile",
|
||||
"graph-result",
|
||||
"graph-frames",
|
||||
"comparison-result",
|
||||
"comparison-frames",
|
||||
"video",
|
||||
"output-root",
|
||||
):
|
||||
parser.add_argument(f"--{name}", type=Path, required=True)
|
||||
for name in (
|
||||
"profile",
|
||||
"graph-result",
|
||||
"graph-frames",
|
||||
"comparison-result",
|
||||
"comparison-frames",
|
||||
"video",
|
||||
"runner",
|
||||
):
|
||||
parser.add_argument(f"--expected-{name}-sha256", required=True)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
try:
|
||||
result = run(parse_args(argv))
|
||||
except M48QCaseMiningError as exc:
|
||||
print(f"M4.8Q case mining refused: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,207 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ReleaseRoot,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||
[string]$RunId,
|
||||
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48q-native-risk-case-mining"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Assert-LastExitCode([string]$Operation) {
|
||||
if ($LASTEXITCODE -ne 0) { throw "$Operation failed with exit code $LASTEXITCODE" }
|
||||
}
|
||||
|
||||
function Get-Sha256([string]$Path) {
|
||||
return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
}
|
||||
|
||||
function Resolve-DDirectory([string]$Path, [string]$Label, [bool]$Create) {
|
||||
if ($Create -and -not (Test-Path -LiteralPath $Path)) {
|
||||
$null = New-Item -ItemType Directory -Path $Path
|
||||
}
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if (
|
||||
-not $item.PSIsContainer -or
|
||||
($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or
|
||||
[IO.Path]::GetPathRoot($item.FullName).TrimEnd("\") -ine "D:"
|
||||
) {
|
||||
throw "$Label must be a real D: directory"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Assert-RegularFile([string]$Path, [string]$Label) {
|
||||
$item = Get-Item -LiteralPath (Resolve-Path -LiteralPath $Path).Path -Force
|
||||
if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
throw "$Label must be a regular file"
|
||||
}
|
||||
return $item.FullName
|
||||
}
|
||||
|
||||
function Convert-ToDockerPath([string]$Path) { return $Path.Replace("\", "/") }
|
||||
|
||||
function Get-Container([string]$Name) {
|
||||
$rows = @((& docker inspect $Name) | ConvertFrom-Json)
|
||||
Assert-LastExitCode "Docker inspection for $Name"
|
||||
if ($rows.Count -ne 1) { throw "Container identity for $Name is not unique" }
|
||||
return $rows[0]
|
||||
}
|
||||
|
||||
if ($env:COMPUTERNAME -cne "DESKTOP-OPJ8J04") {
|
||||
throw "M4.8Q native risk case mining is pinned to DESKTOP-OPJ8J04"
|
||||
}
|
||||
|
||||
$release = Resolve-DDirectory $ReleaseRoot "M4.8Q release root" $false
|
||||
$output = Resolve-DDirectory $OutputRoot "M4.8Q output root" $true
|
||||
$runOutput = Join-Path $output $RunId
|
||||
if (Test-Path -LiteralPath $runOutput) { throw "M4.8Q output already exists" }
|
||||
|
||||
$runner = Assert-RegularFile (
|
||||
Join-Path $release "run_m48q_native_risk_case_mining_worker.py"
|
||||
) "M4.8Q runner"
|
||||
$profile = Assert-RegularFile (
|
||||
Join-Path $release "m48q-native-risk-case-mining-v1.json"
|
||||
) "M4.8Q profile"
|
||||
$runnerSha256 = Get-Sha256 $runner
|
||||
$profileSha256 = Get-Sha256 $profile
|
||||
if ($runnerSha256 -cne "39bb1d810b167d6e97dcb091505b52d4a812d69419dcb32fe23ea5d14d605d67") {
|
||||
throw "M4.8Q runner SHA-256 changed"
|
||||
}
|
||||
if ($profileSha256 -cne "c455055e63578d505edc80b66d67e795916a3d0297cc18bfee5a6af7b032ceda") {
|
||||
throw "M4.8Q profile SHA-256 changed"
|
||||
}
|
||||
|
||||
$graphRoot = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\results\m48n-native-reference-graph-shadow\ravnoves00-full-12fps-v0"
|
||||
) "M4.8Q graph evidence root" $false
|
||||
$comparisonRoot = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\results\m48n-native-vs-704\ravnoves00-full-v0"
|
||||
) "M4.8Q comparison evidence root" $false
|
||||
$graphResult = Assert-RegularFile (Join-Path $graphRoot "result.json") "graph result"
|
||||
$graphFrames = Assert-RegularFile (Join-Path $graphRoot "frames.jsonl") "graph frames"
|
||||
$comparisonResult = Assert-RegularFile (Join-Path $comparisonRoot "result.json") "comparison result"
|
||||
$comparisonFrames = Assert-RegularFile (Join-Path $comparisonRoot "frames.jsonl") "comparison frames"
|
||||
$video = Assert-RegularFile (
|
||||
"D:\NDC_MISSIONCORE\runtime\experiments\e46e\inputs\right-cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8.mp4"
|
||||
) "RAVNOVES00 video"
|
||||
|
||||
$graphResultSha256 = Get-Sha256 $graphResult
|
||||
$graphFramesSha256 = Get-Sha256 $graphFrames
|
||||
$comparisonResultSha256 = Get-Sha256 $comparisonResult
|
||||
$comparisonFramesSha256 = Get-Sha256 $comparisonFrames
|
||||
$videoSha256 = Get-Sha256 $video
|
||||
if ($graphResultSha256 -cne "c5c3a831b1d1c3271161c91fa5c5c40533eb663ca288ed0220334a147aed6e15") {
|
||||
throw "M4.8Q graph result SHA-256 changed"
|
||||
}
|
||||
if ($graphFramesSha256 -cne "b245af969600670d0975e89cae02b44206e3f1328eff48d5b4639b1b2ab57346") {
|
||||
throw "M4.8Q graph frame evidence SHA-256 changed"
|
||||
}
|
||||
if ($comparisonResultSha256 -cne "fd91c2f1f477038d5af51656d510aba39d69ad2e1a387f15d98ade19dd3a0c49") {
|
||||
throw "M4.8Q comparison result SHA-256 changed"
|
||||
}
|
||||
if ($comparisonFramesSha256 -cne "6a35125d4e003edfd8a33b4239bad0910f137fde3a770e9d5baab721cdc145b2") {
|
||||
throw "M4.8Q comparison frame evidence SHA-256 changed"
|
||||
}
|
||||
if ($videoSha256 -cne "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8") {
|
||||
throw "RAVNOVES00 video SHA-256 changed"
|
||||
}
|
||||
|
||||
$media = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-e15-media-pyav180-lz445-v1"
|
||||
) "PyAV dependency" $false
|
||||
$pillow = Resolve-DDirectory (
|
||||
"D:\NDC_MISSIONCORE\runtime\derived\perception-p0-env-v1"
|
||||
) "Pillow dependency" $false
|
||||
$image = (
|
||||
"nvcr.io/nvidia/tritonserver:26.06-py3@" +
|
||||
"sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||
)
|
||||
& docker image inspect $image *> $null
|
||||
Assert-LastExitCode "pinned M4.8Q image inspection"
|
||||
|
||||
$canonicalTriton = Get-Container "ndc-mission-core-triton"
|
||||
if (-not $canonicalTriton.State.Running -or $canonicalTriton.State.Health.Status -cne "healthy") {
|
||||
throw "Canonical Triton must remain healthy during M4.8Q mining"
|
||||
}
|
||||
$canonicalTritonId = [string]$canonicalTriton.Id
|
||||
$runnerName = "ndc-mission-core-m48q-native-risk-case-mining"
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$runnerName$") {
|
||||
throw "M4.8Q bounded container already exists"
|
||||
}
|
||||
|
||||
try {
|
||||
& docker run `
|
||||
--name $runnerName `
|
||||
--label "com.nodedc.product=mission-core" `
|
||||
--label "com.nodedc.stack=ndc-mission-core-compute" `
|
||||
--label "com.nodedc.role=bounded-native-risk-case-mining" `
|
||||
--label "com.nodedc.managed-by=codex-bounded-experiment" `
|
||||
--network none `
|
||||
--read-only `
|
||||
--security-opt "no-new-privileges:true" `
|
||||
--cap-drop ALL `
|
||||
--pids-limit 128 `
|
||||
--cpus 4 `
|
||||
--memory 4g `
|
||||
--tmpfs "/tmp:rw,noexec,nosuid,size=512m" `
|
||||
-e "PYTHONDONTWRITEBYTECODE=1" `
|
||||
-e "PYTHONPATH=/opt/media:/opt/pillow" `
|
||||
-v ((Convert-ToDockerPath $release) + ":/release:ro") `
|
||||
-v ((Convert-ToDockerPath $output) + ":/output:rw") `
|
||||
-v ((Convert-ToDockerPath $media) + ":/opt/media:ro") `
|
||||
-v ((Convert-ToDockerPath $pillow) + ":/opt/pillow:ro") `
|
||||
-v ((Convert-ToDockerPath $graphRoot) + ":/evidence/graph:ro") `
|
||||
-v ((Convert-ToDockerPath $comparisonRoot) + ":/evidence/comparison:ro") `
|
||||
-v ((Convert-ToDockerPath $video) + ":/source/right.mp4:ro") `
|
||||
--entrypoint python3 `
|
||||
$image `
|
||||
/release/run_m48q_native_risk_case_mining_worker.py `
|
||||
--profile /release/m48q-native-risk-case-mining-v1.json `
|
||||
--graph-result /evidence/graph/result.json `
|
||||
--graph-frames /evidence/graph/frames.jsonl `
|
||||
--comparison-result /evidence/comparison/result.json `
|
||||
--comparison-frames /evidence/comparison/frames.jsonl `
|
||||
--video /source/right.mp4 `
|
||||
--output-root ("/output/{0}" -f $RunId) `
|
||||
--expected-profile-sha256 $profileSha256 `
|
||||
--expected-graph-result-sha256 $graphResultSha256 `
|
||||
--expected-graph-frames-sha256 $graphFramesSha256 `
|
||||
--expected-comparison-result-sha256 $comparisonResultSha256 `
|
||||
--expected-comparison-frames-sha256 $comparisonFramesSha256 `
|
||||
--expected-video-sha256 $videoSha256 `
|
||||
--expected-runner-sha256 $runnerSha256
|
||||
Assert-LastExitCode "M4.8Q native risk case mining"
|
||||
if (
|
||||
-not (Test-Path -LiteralPath (Join-Path $runOutput "result.json") -PathType Leaf) -or
|
||||
-not (Test-Path -LiteralPath (Join-Path $runOutput "cases.jsonl") -PathType Leaf) -or
|
||||
@(Get-ChildItem -LiteralPath (Join-Path $runOutput "cases") -Filter "*.jpg" -File).Count -ne 24
|
||||
) {
|
||||
throw "M4.8Q result artifacts are incomplete"
|
||||
}
|
||||
} finally {
|
||||
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$runnerName$") {
|
||||
& docker rm -f $runnerName *> $null
|
||||
}
|
||||
$canonicalAfter = Get-Container "ndc-mission-core-triton"
|
||||
if (
|
||||
[string]$canonicalAfter.Id -cne $canonicalTritonId -or
|
||||
-not $canonicalAfter.State.Running -or
|
||||
$canonicalAfter.State.Health.Status -cne "healthy"
|
||||
) {
|
||||
throw "Canonical Triton changed during M4.8Q mining"
|
||||
}
|
||||
}
|
||||
|
||||
$result = Get-Content -LiteralPath (Join-Path $runOutput "result.json") -Raw | ConvertFrom-Json
|
||||
[pscustomobject]@{
|
||||
run_id = $RunId
|
||||
output_root = $runOutput
|
||||
status = $result.status
|
||||
case_count = $result.selection.case_count
|
||||
report_identity_sha256 = $result.report_identity_sha256
|
||||
canonical_triton = "healthy-and-unchanged"
|
||||
} | ConvertTo-Json -Depth 4
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUNNER_PATH = (
|
||||
REPOSITORY_ROOT / "experiments" / "perception" / "run_m48q_native_risk_case_mining_worker.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("m48q_case_mining", RUNNER_PATH)
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = MODULE
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def candidate(sequence: int, *buckets: str):
|
||||
return MODULE.FrameCandidate(
|
||||
sequence=sequence,
|
||||
frame_id=f"frame-{sequence:06d}",
|
||||
evidence_time_ns=sequence * 1_000_000,
|
||||
proposals=(),
|
||||
native_count=0,
|
||||
legacy_count=0,
|
||||
matched_count=0,
|
||||
buckets=frozenset(buckets),
|
||||
)
|
||||
|
||||
|
||||
def test_selection_is_deterministic_balanced_and_separated() -> None:
|
||||
candidates = [
|
||||
candidate(sequence, "person" if sequence % 2 == 0 else "vehicle")
|
||||
for sequence in range(0, 400, 5)
|
||||
]
|
||||
quotas = {"person": 3, "vehicle": 3}
|
||||
|
||||
first = MODULE.select_cases(
|
||||
candidates,
|
||||
bucket_quotas=quotas,
|
||||
minimum_sequence_separation=10,
|
||||
)
|
||||
second = MODULE.select_cases(
|
||||
candidates,
|
||||
bucket_quotas=quotas,
|
||||
minimum_sequence_separation=10,
|
||||
)
|
||||
|
||||
assert [item.sequence for item in first] == [item.sequence for item in second]
|
||||
assert len(first) == 6
|
||||
assert all(
|
||||
abs(left.sequence - right.sequence) >= 10
|
||||
for index, left in enumerate(first)
|
||||
for right in first[index + 1 :]
|
||||
)
|
||||
|
||||
|
||||
def test_selection_refuses_missing_bucket_coverage() -> None:
|
||||
with pytest.raises(MODULE.M48QCaseMiningError, match="animal produced 0/1"):
|
||||
MODULE.select_cases(
|
||||
[candidate(0, "person")],
|
||||
bucket_quotas={"animal": 1},
|
||||
minimum_sequence_separation=1,
|
||||
)
|
||||
|
||||
|
||||
def test_profile_freezes_raw_raster_and_false_authority() -> None:
|
||||
import json
|
||||
|
||||
profile = json.loads(
|
||||
(
|
||||
REPOSITORY_ROOT / "config" / "perception" / "m48q-native-risk-case-mining-v1.json"
|
||||
).read_text("utf-8")
|
||||
)
|
||||
|
||||
assert profile["source"] == {
|
||||
"source_id": "RAVNOVES00",
|
||||
"frame_count": 4489,
|
||||
"raster_width": 800,
|
||||
"raster_height": 600,
|
||||
"video_sha256": "cadd1696ff000904eb78633a0a8418104b8024f178b91f3421789021ccb160e8",
|
||||
"geometric_resampling": False,
|
||||
"rectification": False,
|
||||
"warp": False,
|
||||
}
|
||||
assert sum(profile["selection"]["bucket_quotas"].values()) == 24
|
||||
assert profile["scope"]["quality_evaluated"] is False
|
||||
assert profile["authority"] == MODULE.FALSE_AUTHORITY
|
||||
Reference in New Issue
Block a user