feat(perception): qualify M4.8T semantic identity
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.m48t-risk-quality-temporal-profile/v1",
|
||||||
|
"profile_id": "m48t-coco2017-risk-quality-temporal/v1",
|
||||||
|
"candidate": {
|
||||||
|
"provider_id": "triton-rf-detr-large-coco-risk-fp16-shadow/v0",
|
||||||
|
"model_id": "rf_detr_large:1",
|
||||||
|
"minimum_score": 0.25,
|
||||||
|
"source_projection": "stretch-to-800x600-then-rf-detr-704x704"
|
||||||
|
},
|
||||||
|
"dataset": {
|
||||||
|
"dataset_id": "coco-2017-val",
|
||||||
|
"images_url": "http://images.cocodataset.org/zips/val2017.zip",
|
||||||
|
"annotations_url": "http://images.cocodataset.org/annotations/annotations_trainval2017.zip",
|
||||||
|
"annotation_document": "annotations/instances_val2017.json",
|
||||||
|
"split": "val2017",
|
||||||
|
"independent_human_annotations": true,
|
||||||
|
"include_iscrowd": false,
|
||||||
|
"minimum_projected_box_area_pixels": 64.0,
|
||||||
|
"maximum_projected_box_area_fraction": 0.5
|
||||||
|
},
|
||||||
|
"risk_classes": {
|
||||||
|
"person": ["person"],
|
||||||
|
"animal": ["bird", "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe"],
|
||||||
|
"light-road-user": ["bicycle", "motorcycle", "skateboard"],
|
||||||
|
"vehicle": ["car", "bus", "truck"]
|
||||||
|
},
|
||||||
|
"matching": {
|
||||||
|
"iou_threshold": 0.5,
|
||||||
|
"method": "score-ordered-greedy-exact-class",
|
||||||
|
"family_confusion_matching": "score-ordered-greedy-same-family",
|
||||||
|
"small_area_upper_pixels": 1024.0,
|
||||||
|
"medium_area_upper_pixels": 9216.0
|
||||||
|
},
|
||||||
|
"quality_gates": {
|
||||||
|
"minimum_truth_instances": 1000,
|
||||||
|
"minimum_micro_precision": 0.8,
|
||||||
|
"minimum_micro_recall": 0.75,
|
||||||
|
"minimum_medium_large_recall": 0.85,
|
||||||
|
"minimum_family_recall": {
|
||||||
|
"person": 0.8,
|
||||||
|
"animal": 0.75,
|
||||||
|
"light-road-user": 0.65,
|
||||||
|
"vehicle": 0.85
|
||||||
|
},
|
||||||
|
"minimum_class_truth_instances": 25,
|
||||||
|
"minimum_qualified_class_recall": 0.55,
|
||||||
|
"maximum_empty_prediction_risk_image_fraction": 0.1
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"history_size": 5,
|
||||||
|
"initial_confirmation_observations": 2,
|
||||||
|
"switch_confirmation_observations": 3,
|
||||||
|
"semantic_hold_seconds": 0.3,
|
||||||
|
"state_expiry_seconds": 1.0,
|
||||||
|
"maximum_active_components": 512,
|
||||||
|
"cross_family_conflict_fallback": "unknown",
|
||||||
|
"association_uses_semantic_class": false,
|
||||||
|
"occupancy_uses_semantic_class": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"child_adult_distinction_evaluated": false,
|
||||||
|
"unknown_moving_detection_evaluated": false,
|
||||||
|
"object_presence_evaluated": true,
|
||||||
|
"semantic_family_evaluated": true,
|
||||||
|
"temporal_stability_evaluated": true,
|
||||||
|
"risk_policy_evaluated": false
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"ground_truth_for_ravnoves00": false,
|
||||||
|
"candidate_accepted": false,
|
||||||
|
"commands_enabled": false,
|
||||||
|
"actuation_allowed": false,
|
||||||
|
"navigation_or_safety_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the frozen RF-DETR risk contour on independent COCO 2017 val truth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from k1link.perception.m48t_risk_quality import (
|
||||||
|
CocoRiskImage,
|
||||||
|
RiskPrediction,
|
||||||
|
RiskTruth,
|
||||||
|
load_coco_risk_truth,
|
||||||
|
load_m48t_risk_quality_profile,
|
||||||
|
score_risk_quality,
|
||||||
|
)
|
||||||
|
from k1link.perception.rf_detr_object_detector import (
|
||||||
|
TritonRfDetrHttpInferenceBackend,
|
||||||
|
postprocess_rf_detr,
|
||||||
|
preprocess_raw_kb4_rf_detr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--profile", type=Path, required=True)
|
||||||
|
parser.add_argument("--annotations", type=Path, required=True)
|
||||||
|
parser.add_argument("--images-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--triton-origin", default="http://127.0.0.1:8000")
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--predictions", type=Path, required=True)
|
||||||
|
parser.add_argument("--failures", type=Path, required=True)
|
||||||
|
parser.add_argument("--progress", type=Path, required=True)
|
||||||
|
parser.add_argument("--review-root", type=Path, required=True)
|
||||||
|
parser.add_argument("--runtime-artifact-sha256", required=True)
|
||||||
|
parser.add_argument("--runner-sha256", required=True)
|
||||||
|
parser.add_argument("--images-archive-sha256", required=True)
|
||||||
|
parser.add_argument("--annotations-document-sha256", required=True)
|
||||||
|
parser.add_argument("--maximum-images", type=int, default=0)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
arguments = parse_arguments()
|
||||||
|
_require_sha256(arguments.runtime_artifact_sha256, "runtime artifact")
|
||||||
|
_require_sha256(arguments.runner_sha256, "runner")
|
||||||
|
_require_sha256(arguments.images_archive_sha256, "images archive")
|
||||||
|
_require_sha256(arguments.annotations_document_sha256, "annotations document")
|
||||||
|
if arguments.maximum_images < 0:
|
||||||
|
raise RuntimeError("maximum images cannot be negative")
|
||||||
|
for target in (
|
||||||
|
arguments.output,
|
||||||
|
arguments.predictions,
|
||||||
|
arguments.failures,
|
||||||
|
arguments.progress,
|
||||||
|
):
|
||||||
|
if target.exists():
|
||||||
|
raise RuntimeError(f"output already exists: {target}")
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if arguments.review_root.exists():
|
||||||
|
raise RuntimeError("review output already exists")
|
||||||
|
arguments.review_root.mkdir(parents=True)
|
||||||
|
|
||||||
|
profile = load_m48t_risk_quality_profile(arguments.profile)
|
||||||
|
images, truth = load_coco_risk_truth(arguments.annotations, profile)
|
||||||
|
if arguments.maximum_images:
|
||||||
|
images = images[: arguments.maximum_images]
|
||||||
|
image_ids = {item.image_id for item in images}
|
||||||
|
truth = tuple(item for item in truth if item.image_id in image_ids)
|
||||||
|
if not images or not truth:
|
||||||
|
raise RuntimeError("selected COCO risk set is empty")
|
||||||
|
|
||||||
|
mask = np.ones((600, 800), dtype=np.bool_)
|
||||||
|
class_to_family = profile.class_to_family
|
||||||
|
predictions: list[RiskPrediction] = []
|
||||||
|
image_timings_ms: list[float] = []
|
||||||
|
inference_timings_ms: list[float] = []
|
||||||
|
rejected: Counter[str] = Counter()
|
||||||
|
started_at = time.time_ns()
|
||||||
|
backend = TritonRfDetrHttpInferenceBackend(arguments.triton_origin)
|
||||||
|
with arguments.progress.open("x", encoding="utf-8") as progress:
|
||||||
|
try:
|
||||||
|
for image_index, image in enumerate(images, start=1):
|
||||||
|
loop_started = time.perf_counter_ns()
|
||||||
|
image_path = (arguments.images_root / image.file_name).resolve(strict=True)
|
||||||
|
with Image.open(image_path) as opened:
|
||||||
|
rgb = np.asarray(
|
||||||
|
opened.convert("RGB").resize((800, 600), Image.Resampling.BILINEAR),
|
||||||
|
dtype=np.uint8,
|
||||||
|
)
|
||||||
|
image_bgr = np.ascontiguousarray(rgb[:, :, ::-1])
|
||||||
|
tensor = preprocess_raw_kb4_rf_detr(image_bgr, mask)
|
||||||
|
inference_started = time.perf_counter_ns()
|
||||||
|
output = backend.infer(tensor)
|
||||||
|
inference_ms = (time.perf_counter_ns() - inference_started) / 1_000_000
|
||||||
|
inference_timings_ms.append(inference_ms)
|
||||||
|
processed = postprocess_rf_detr(output, mask)
|
||||||
|
rejected.update(dict(processed.rejected))
|
||||||
|
for detection_index, detection in enumerate(processed.detections, start=1):
|
||||||
|
family = class_to_family.get(detection.label)
|
||||||
|
if family is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"RF-DETR emitted a class outside the frozen risk profile"
|
||||||
|
)
|
||||||
|
predictions.append(
|
||||||
|
RiskPrediction(
|
||||||
|
image_id=image.image_id,
|
||||||
|
prediction_id=f"{image.image_id:012d}:{detection_index:03d}",
|
||||||
|
class_name=detection.label,
|
||||||
|
family=family,
|
||||||
|
score=detection.score,
|
||||||
|
bbox_xyxy=detection.bbox_xyxy,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
image_ms = (time.perf_counter_ns() - loop_started) / 1_000_000
|
||||||
|
image_timings_ms.append(image_ms)
|
||||||
|
if image_index == 1 or image_index % 100 == 0 or image_index == len(images):
|
||||||
|
progress.write(
|
||||||
|
_canonical_json(
|
||||||
|
{
|
||||||
|
"schema_version": "missioncore.m48t-risk-quality-progress/v1",
|
||||||
|
"completed_images": image_index,
|
||||||
|
"total_images": len(images),
|
||||||
|
"prediction_count": len(predictions),
|
||||||
|
"last_image_ms": image_ms,
|
||||||
|
"last_inference_ms": inference_ms,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
progress.flush()
|
||||||
|
finally:
|
||||||
|
backend.close()
|
||||||
|
|
||||||
|
result = score_risk_quality(
|
||||||
|
images=images,
|
||||||
|
truth=truth,
|
||||||
|
predictions=tuple(predictions),
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
completed_at = time.time_ns()
|
||||||
|
prediction_rows = tuple(_prediction_row(item) for item in predictions)
|
||||||
|
_write_jsonl(arguments.predictions, prediction_rows)
|
||||||
|
_write_jsonl(arguments.failures, result.failures)
|
||||||
|
review_files = _render_review_cases(
|
||||||
|
images_root=arguments.images_root,
|
||||||
|
review_root=arguments.review_root,
|
||||||
|
images=images,
|
||||||
|
truth=truth,
|
||||||
|
predictions=tuple(predictions),
|
||||||
|
failures=result.failures,
|
||||||
|
)
|
||||||
|
report = dict(result.report)
|
||||||
|
report["execution"] = {
|
||||||
|
"worker": "DESKTOP-OPJ8J04",
|
||||||
|
"started_at_unix_ns": started_at,
|
||||||
|
"completed_at_unix_ns": completed_at,
|
||||||
|
"duration_seconds": (completed_at - started_at) / 1_000_000_000,
|
||||||
|
"image_timing_ms": _distribution(image_timings_ms),
|
||||||
|
"triton_inference_ms": _distribution(inference_timings_ms),
|
||||||
|
"effective_images_per_second": len(images)
|
||||||
|
/ ((completed_at - started_at) / 1_000_000_000),
|
||||||
|
"timing_is_admission_evidence": False,
|
||||||
|
}
|
||||||
|
report["provenance"] = {
|
||||||
|
"profile_sha256": profile.profile_sha256,
|
||||||
|
"annotations_document_sha256": _sha256(arguments.annotations),
|
||||||
|
"images_archive_sha256": arguments.images_archive_sha256,
|
||||||
|
"annotations_document_expected_sha256": arguments.annotations_document_sha256,
|
||||||
|
"runtime_artifact_sha256": arguments.runtime_artifact_sha256,
|
||||||
|
"runner_sha256": arguments.runner_sha256,
|
||||||
|
"predictions_sha256": _sha256(arguments.predictions),
|
||||||
|
"failures_sha256": _sha256(arguments.failures),
|
||||||
|
}
|
||||||
|
report["artifacts"] = {
|
||||||
|
"predictions": arguments.predictions.name,
|
||||||
|
"failures": arguments.failures.name,
|
||||||
|
"progress": arguments.progress.name,
|
||||||
|
"review_files": review_files,
|
||||||
|
}
|
||||||
|
report["detector_rejections"] = dict(sorted(rejected.items()))
|
||||||
|
report["report_identity_sha256"] = hashlib.sha256(
|
||||||
|
_canonical_json(
|
||||||
|
{
|
||||||
|
"profile_sha256": profile.profile_sha256,
|
||||||
|
"dataset": report["dataset"],
|
||||||
|
"counts": report["counts"],
|
||||||
|
"metrics": report["metrics"],
|
||||||
|
"failure_buckets": report["failure_buckets"],
|
||||||
|
"quality_gates": report["quality_gates"],
|
||||||
|
"provenance": report["provenance"],
|
||||||
|
}
|
||||||
|
).encode()
|
||||||
|
).hexdigest()
|
||||||
|
arguments.output.write_text(_canonical_json(report) + "\n", "utf-8")
|
||||||
|
quality_gates = report.get("quality_gates")
|
||||||
|
if not isinstance(quality_gates, dict) or not isinstance(
|
||||||
|
quality_gates.get("passed"), bool
|
||||||
|
):
|
||||||
|
raise RuntimeError("quality gate result is invalid")
|
||||||
|
print(
|
||||||
|
_canonical_json(
|
||||||
|
{
|
||||||
|
"result": str(arguments.output),
|
||||||
|
"risk_images": len(images),
|
||||||
|
"truth_instances": len(truth),
|
||||||
|
"predictions": len(predictions),
|
||||||
|
"quality_gate_passed": quality_gates["passed"],
|
||||||
|
"report_identity_sha256": report["report_identity_sha256"],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction_row(item: RiskPrediction) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"image_id": item.image_id,
|
||||||
|
"prediction_id": item.prediction_id,
|
||||||
|
"class_name": item.class_name,
|
||||||
|
"family": item.family,
|
||||||
|
"score": item.score,
|
||||||
|
"bbox_xyxy": list(item.bbox_xyxy),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _render_review_cases(
|
||||||
|
*,
|
||||||
|
images_root: Path,
|
||||||
|
review_root: Path,
|
||||||
|
images: tuple[CocoRiskImage, ...],
|
||||||
|
truth: tuple[RiskTruth, ...],
|
||||||
|
predictions: tuple[RiskPrediction, ...],
|
||||||
|
failures: tuple[dict[str, object], ...],
|
||||||
|
) -> list[str]:
|
||||||
|
image_by_id = {item.image_id: item for item in images}
|
||||||
|
selected_ids: list[int] = []
|
||||||
|
for failure in failures:
|
||||||
|
if failure.get("kind") != "false-negative":
|
||||||
|
continue
|
||||||
|
raw_image_id = failure.get("image_id")
|
||||||
|
if not isinstance(raw_image_id, int) or isinstance(raw_image_id, bool):
|
||||||
|
raise RuntimeError("failure image id is invalid")
|
||||||
|
image_id = raw_image_id
|
||||||
|
if image_id not in selected_ids:
|
||||||
|
selected_ids.append(image_id)
|
||||||
|
if len(selected_ids) == 16:
|
||||||
|
break
|
||||||
|
result: list[str] = []
|
||||||
|
for image_id in selected_ids:
|
||||||
|
metadata = image_by_id[image_id]
|
||||||
|
with Image.open((images_root / metadata.file_name).resolve(strict=True)) as opened:
|
||||||
|
canvas = opened.convert("RGB").resize((800, 600), Image.Resampling.BILINEAR)
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
for truth_item in truth:
|
||||||
|
if truth_item.image_id != image_id:
|
||||||
|
continue
|
||||||
|
draw.rectangle(truth_item.bbox_xyxy, outline=(80, 230, 120), width=3)
|
||||||
|
draw.text(
|
||||||
|
(truth_item.bbox_xyxy[0] + 2, truth_item.bbox_xyxy[1] + 2),
|
||||||
|
f"GT {truth_item.class_name}",
|
||||||
|
fill=(80, 230, 120),
|
||||||
|
)
|
||||||
|
for prediction_item in predictions:
|
||||||
|
if prediction_item.image_id != image_id:
|
||||||
|
continue
|
||||||
|
draw.rectangle(prediction_item.bbox_xyxy, outline=(255, 200, 50), width=2)
|
||||||
|
draw.text(
|
||||||
|
(prediction_item.bbox_xyxy[0] + 2, prediction_item.bbox_xyxy[3] - 13),
|
||||||
|
f"P {prediction_item.class_name} {prediction_item.score:.2f}",
|
||||||
|
fill=(255, 200, 50),
|
||||||
|
)
|
||||||
|
name = f"review-{image_id:012d}.jpg"
|
||||||
|
canvas.save(review_root / name, format="JPEG", quality=90, optimize=True)
|
||||||
|
result.append(f"review/{name}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _distribution(values: list[float]) -> dict[str, float]:
|
||||||
|
if not values:
|
||||||
|
raise RuntimeError("timing distribution is empty")
|
||||||
|
ordered = sorted(values)
|
||||||
|
return {
|
||||||
|
"count": float(len(ordered)),
|
||||||
|
"mean": statistics.fmean(ordered),
|
||||||
|
"p50": _percentile(ordered, 0.50),
|
||||||
|
"p95": _percentile(ordered, 0.95),
|
||||||
|
"p99": _percentile(ordered, 0.99),
|
||||||
|
"maximum": ordered[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], quantile: float) -> float:
|
||||||
|
position = (len(values) - 1) * quantile
|
||||||
|
lower = math.floor(position)
|
||||||
|
upper = math.ceil(position)
|
||||||
|
if lower == upper:
|
||||||
|
return values[lower]
|
||||||
|
return values[lower] + (values[upper] - values[lower]) * (position - lower)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_jsonl(path: Path, rows: tuple[dict[str, object], ...]) -> None:
|
||||||
|
with path.open("x", encoding="utf-8") as handle:
|
||||||
|
for row in rows:
|
||||||
|
handle.write(_canonical_json(row) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> str:
|
||||||
|
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(path: Path) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with path.open("rb") as handle:
|
||||||
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_sha256(value: str, label: str) -> None:
|
||||||
|
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
|
||||||
|
raise RuntimeError(f"{label} SHA-256 is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Replay M4.8T semantic stabilization over geometry-owned component ids."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.perception.m48t_risk_quality import (
|
||||||
|
BoundedTemporalSemanticIdentity,
|
||||||
|
TemporalSemanticObservation,
|
||||||
|
load_m48t_risk_quality_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_arguments() -> argparse.Namespace:
|
||||||
|
repository = Path(__file__).resolve().parents[2]
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
"--profile",
|
||||||
|
type=Path,
|
||||||
|
default=repository / "config/perception/m48t-risk-quality-temporal-v1.json",
|
||||||
|
)
|
||||||
|
parser.add_argument("--frames", type=Path, required=True)
|
||||||
|
parser.add_argument("--expected-frames-sha256", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
arguments = parse_arguments()
|
||||||
|
if arguments.output.exists():
|
||||||
|
raise RuntimeError("temporal semantic output already exists")
|
||||||
|
if not _is_sha256(arguments.expected_frames_sha256):
|
||||||
|
raise RuntimeError("expected frame ledger SHA-256 is invalid")
|
||||||
|
profile = load_m48t_risk_quality_profile(arguments.profile)
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(profile)
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
frames = 0
|
||||||
|
publications = 0
|
||||||
|
semantic_current = 0
|
||||||
|
selected_publications = 0
|
||||||
|
raw_switches = 0
|
||||||
|
stable_switches = 0
|
||||||
|
raw_family_switches = 0
|
||||||
|
stable_family_switches = 0
|
||||||
|
rolling_retained_skipped = 0
|
||||||
|
resolutions: Counter[str] = Counter()
|
||||||
|
stable_families: Counter[str] = Counter()
|
||||||
|
last_raw: dict[str, str] = {}
|
||||||
|
last_stable: dict[str, str] = {}
|
||||||
|
class_to_family = profile.class_to_family
|
||||||
|
|
||||||
|
with arguments.frames.expanduser().resolve(strict=True).open("rb") as handle:
|
||||||
|
for line_number, raw_line in enumerate(handle, start=1):
|
||||||
|
digest.update(raw_line)
|
||||||
|
try:
|
||||||
|
document = json.loads(raw_line)
|
||||||
|
frame_time_ns = document["source_envelope"]["timestamps"]["source_ns"]
|
||||||
|
occupied = document["delivery"]["obstacle_map"]["occupied"]
|
||||||
|
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
raise RuntimeError(f"frame ledger row {line_number} is invalid") from exc
|
||||||
|
if not isinstance(frame_time_ns, int) or not isinstance(occupied, list):
|
||||||
|
raise RuntimeError(f"frame ledger row {line_number} contract changed")
|
||||||
|
frames += 1
|
||||||
|
for value in occupied:
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RuntimeError("temporal obstacle is invalid")
|
||||||
|
component_id = value.get("component_id")
|
||||||
|
state = value.get("state")
|
||||||
|
if state == "retained":
|
||||||
|
rolling_retained_skipped += 1
|
||||||
|
continue
|
||||||
|
raw_class = value.get("semantic_hint") if state == "current" else None
|
||||||
|
if not isinstance(component_id, str) or state not in {
|
||||||
|
"current",
|
||||||
|
"held",
|
||||||
|
"expired",
|
||||||
|
}:
|
||||||
|
raise RuntimeError("temporal obstacle identity or state changed")
|
||||||
|
if raw_class is not None and not isinstance(raw_class, str):
|
||||||
|
raise RuntimeError("temporal semantic hint is invalid")
|
||||||
|
if raw_class is not None:
|
||||||
|
semantic_current += 1
|
||||||
|
previous_raw = last_raw.get(component_id)
|
||||||
|
if previous_raw is not None and previous_raw != raw_class:
|
||||||
|
raw_switches += 1
|
||||||
|
if class_to_family[previous_raw] != class_to_family[raw_class]:
|
||||||
|
raw_family_switches += 1
|
||||||
|
last_raw[component_id] = raw_class
|
||||||
|
result = stabilizer.update(
|
||||||
|
TemporalSemanticObservation(
|
||||||
|
component_id=component_id,
|
||||||
|
evidence_time_ns=frame_time_ns,
|
||||||
|
raw_class_name=raw_class,
|
||||||
|
currentness=state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
publications += 1
|
||||||
|
resolutions[result.resolution] += 1
|
||||||
|
if result.selected_class_name is not None:
|
||||||
|
selected_publications += 1
|
||||||
|
if result.selected_family is None:
|
||||||
|
raise RuntimeError("selected semantic family is missing")
|
||||||
|
stable_families[result.selected_family] += 1
|
||||||
|
previous_stable = last_stable.get(component_id)
|
||||||
|
if (
|
||||||
|
previous_stable is not None
|
||||||
|
and previous_stable != result.selected_class_name
|
||||||
|
):
|
||||||
|
stable_switches += 1
|
||||||
|
if (
|
||||||
|
class_to_family[previous_stable]
|
||||||
|
!= class_to_family[result.selected_class_name]
|
||||||
|
):
|
||||||
|
stable_family_switches += 1
|
||||||
|
last_stable[component_id] = result.selected_class_name
|
||||||
|
|
||||||
|
frames_sha256 = digest.hexdigest()
|
||||||
|
if frames_sha256 != arguments.expected_frames_sha256:
|
||||||
|
raise RuntimeError("temporal frame ledger SHA-256 changed")
|
||||||
|
snapshot = stabilizer.snapshot()
|
||||||
|
gate_checks = {
|
||||||
|
"all-publications-accounted": snapshot.input_observations == publications,
|
||||||
|
"bounded-active-components": (
|
||||||
|
snapshot.peak_active_components <= profile.temporal.maximum_active_components
|
||||||
|
),
|
||||||
|
"stable-switches-not-greater-than-raw-switches": stable_switches <= raw_switches,
|
||||||
|
"stable-family-switches-not-greater-than-raw-family-switches": (
|
||||||
|
stable_family_switches <= raw_family_switches
|
||||||
|
),
|
||||||
|
"association-remains-class-independent": True,
|
||||||
|
"occupancy-remains-class-independent": True,
|
||||||
|
}
|
||||||
|
report = {
|
||||||
|
"schema_version": "missioncore.m48t-temporal-semantic-shadow/v1",
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"profile_sha256": profile.profile_sha256,
|
||||||
|
"source": {
|
||||||
|
"source_id": "RAVNOVES00",
|
||||||
|
"frame_ledger": str(arguments.frames),
|
||||||
|
"frame_ledger_sha256": frames_sha256,
|
||||||
|
"frames": frames,
|
||||||
|
"publications": publications,
|
||||||
|
"independent_semantic_truth_available": False,
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"semantic_current_observations": semantic_current,
|
||||||
|
"rolling_retained_publications_skipped": rolling_retained_skipped,
|
||||||
|
"selected_publications": selected_publications,
|
||||||
|
"raw_class_switches": raw_switches,
|
||||||
|
"stable_class_switches": stable_switches,
|
||||||
|
"suppressed_or_deferred_class_switches": raw_switches - stable_switches,
|
||||||
|
"raw_family_switches": raw_family_switches,
|
||||||
|
"stable_family_switches": stable_family_switches,
|
||||||
|
"suppressed_or_deferred_family_switches": (
|
||||||
|
raw_family_switches - stable_family_switches
|
||||||
|
),
|
||||||
|
"resolutions": dict(sorted(resolutions.items())),
|
||||||
|
"stable_family_publications": dict(sorted(stable_families.items())),
|
||||||
|
"snapshot": {
|
||||||
|
field: getattr(snapshot, field)
|
||||||
|
for field in snapshot.__dataclass_fields__
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"temporal_invariant_gate": {
|
||||||
|
"checks": gate_checks,
|
||||||
|
"passed": all(gate_checks.values()),
|
||||||
|
"semantic_quality_accepted": False,
|
||||||
|
},
|
||||||
|
"policy": {
|
||||||
|
"initial_confirmation_observations": (
|
||||||
|
profile.temporal.initial_confirmation_observations
|
||||||
|
),
|
||||||
|
"switch_confirmation_observations": (
|
||||||
|
profile.temporal.switch_confirmation_observations
|
||||||
|
),
|
||||||
|
"semantic_hold_seconds": profile.temporal.semantic_hold_seconds,
|
||||||
|
"state_expiry_seconds": profile.temporal.state_expiry_seconds,
|
||||||
|
"history_size": profile.temporal.history_size,
|
||||||
|
"maximum_active_components": profile.temporal.maximum_active_components,
|
||||||
|
"cross_family_conflict_fallback": "unknown",
|
||||||
|
"association_uses_semantic_class": False,
|
||||||
|
"occupancy_uses_semantic_class": False,
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"ground_truth_for_ravnoves00": False,
|
||||||
|
"candidate_accepted": False,
|
||||||
|
"commands_enabled": False,
|
||||||
|
"actuation_allowed": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
arguments.output.write_text(
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"output": str(arguments.output),
|
||||||
|
"frames": frames,
|
||||||
|
"publications": publications,
|
||||||
|
"raw_class_switches": raw_switches,
|
||||||
|
"stable_class_switches": stable_switches,
|
||||||
|
"invariant_gate_passed": all(gate_checks.values()),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_sha256(value: object) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(value, str)
|
||||||
|
and len(value) == 64
|
||||||
|
and all(character in "0123456789abcdef" for character in value)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$ReleaseRoot,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^[a-f0-9]{64}$")]
|
||||||
|
[string]$ExpectedWheelSha256,
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[ValidatePattern("^[A-Za-z0-9._-]{1,96}$")]
|
||||||
|
[string]$RunId,
|
||||||
|
[ValidateRange(0, 5000)]
|
||||||
|
[int]$MaximumImages = 0,
|
||||||
|
[string]$DatasetRoot = "D:\NDC_MISSIONCORE\datasets\coco-2017-val",
|
||||||
|
[string]$OutputRoot = "D:\NDC_MISSIONCORE\runtime\results\m48t-risk-quality"
|
||||||
|
)
|
||||||
|
|
||||||
|
$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 Ensure-PinnedMirrorFile(
|
||||||
|
[string]$Path,
|
||||||
|
[string]$Url,
|
||||||
|
[long]$ExpectedLength,
|
||||||
|
[string]$ExpectedSha256,
|
||||||
|
[string]$Label
|
||||||
|
) {
|
||||||
|
if (Test-Path -LiteralPath $Path -PathType Leaf) {
|
||||||
|
if (
|
||||||
|
(Get-Item -LiteralPath $Path).Length -ne $ExpectedLength -or
|
||||||
|
(Get-Sha256 $Path) -cne $ExpectedSha256
|
||||||
|
) {
|
||||||
|
Remove-Item -LiteralPath $Path -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
|
||||||
|
$partial = $Path + ".partial"
|
||||||
|
if (Test-Path -LiteralPath $partial) { Remove-Item -LiteralPath $partial -Force }
|
||||||
|
& curl.exe --fail --location --retry 5 `
|
||||||
|
--speed-limit 1048576 --speed-time 30 --output $partial $Url
|
||||||
|
Assert-LastExitCode "$Label download"
|
||||||
|
if (
|
||||||
|
(Get-Item -LiteralPath $partial).Length -ne $ExpectedLength -or
|
||||||
|
(Get-Sha256 $partial) -cne $ExpectedSha256
|
||||||
|
) {
|
||||||
|
throw "$Label length or SHA-256 changed"
|
||||||
|
}
|
||||||
|
Move-Item -LiteralPath $partial -Destination $Path
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(Get-Item -LiteralPath $Path).Length -ne $ExpectedLength -or
|
||||||
|
(Get-Sha256 $Path) -cne $ExpectedSha256
|
||||||
|
) {
|
||||||
|
throw "$Label length or SHA-256 changed"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 "M48T risk quality is pinned to DESKTOP-OPJ8J04"
|
||||||
|
}
|
||||||
|
|
||||||
|
$release = Resolve-DDirectory $ReleaseRoot "M48T release root" $false
|
||||||
|
$dataset = Resolve-DDirectory $DatasetRoot "M48T dataset root" $true
|
||||||
|
$output = Resolve-DDirectory $OutputRoot "M48T output root" $true
|
||||||
|
$runOutput = Join-Path $output $RunId
|
||||||
|
if (Test-Path -LiteralPath $runOutput) { throw "M48T run output already exists" }
|
||||||
|
$null = New-Item -ItemType Directory -Path $runOutput
|
||||||
|
$runOutput = Resolve-DDirectory $runOutput "M48T run output" $false
|
||||||
|
|
||||||
|
$wheel = Assert-RegularFile (
|
||||||
|
(Join-Path $release "nodedc_mission_core-0.1.0-py3-none-any.whl")
|
||||||
|
) "M48T wheel"
|
||||||
|
if ((Get-Sha256 $wheel) -cne $ExpectedWheelSha256) { throw "M48T wheel SHA-256 changed" }
|
||||||
|
$profile = Assert-RegularFile (
|
||||||
|
(Join-Path $release "m48t-risk-quality-temporal-v1.json")
|
||||||
|
) "M48T profile"
|
||||||
|
$runner = Assert-RegularFile (
|
||||||
|
(Join-Path $release "run_m48t_coco_risk_quality_worker.py")
|
||||||
|
) "M48T runner"
|
||||||
|
$runnerSha256 = Get-Sha256 $runner
|
||||||
|
|
||||||
|
$imagesArchive = Join-Path $dataset "val2017.zip"
|
||||||
|
$annotations = Join-Path $dataset "instances_val2017.json"
|
||||||
|
# These mirrors rehost the unmodified official COCO assets and publish pinned SHA-256 digests.
|
||||||
|
Ensure-PinnedMirrorFile $imagesArchive `
|
||||||
|
"https://huggingface.co/datasets/pcuenq/coco-2017-mirror/resolve/main/val2017.zip?download=true" `
|
||||||
|
815585330 `
|
||||||
|
"4f7e2ccb2866ec5041993c9cf2a952bbed69647b115d0f74da7ce8f4bef82f05" `
|
||||||
|
"COCO val2017"
|
||||||
|
Ensure-PinnedMirrorFile $annotations `
|
||||||
|
"https://huggingface.co/datasets/LibreYOLO/coco2017/resolve/main/instances_val2017.json?download=true" `
|
||||||
|
19987840 `
|
||||||
|
"e8c7f7908f1d7278341fae127d0da654f102f11bd7b21d8aeefa635b8c810b6f" `
|
||||||
|
"COCO instances_val2017"
|
||||||
|
$imagesArchive = Assert-RegularFile $imagesArchive "COCO images archive"
|
||||||
|
$annotations = Assert-RegularFile $annotations "COCO val2017 instances"
|
||||||
|
$imagesArchiveSha256 = Get-Sha256 $imagesArchive
|
||||||
|
$annotationsDocumentSha256 = Get-Sha256 $annotations
|
||||||
|
|
||||||
|
$imagesRoot = Join-Path $dataset "val2017"
|
||||||
|
if (-not (Test-Path -LiteralPath $imagesRoot -PathType Container)) {
|
||||||
|
& tar.exe -xf $imagesArchive -C $dataset
|
||||||
|
Assert-LastExitCode "COCO val2017 extraction"
|
||||||
|
}
|
||||||
|
$imagesRoot = Resolve-DDirectory $imagesRoot "COCO val2017 images" $false
|
||||||
|
if (@(Get-ChildItem -LiteralPath $imagesRoot -File -Filter "*.jpg").Count -ne 5000) {
|
||||||
|
throw "COCO val2017 image count changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$experimentRoot = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\experiments\m48t-fixed-detector-20260825T095425Z"
|
||||||
|
) "M48T RF-DETR experiment root" $false
|
||||||
|
$modelRoot = Resolve-DDirectory (
|
||||||
|
(Join-Path $experimentRoot "triton-models")
|
||||||
|
) "M48T RF-DETR model root" $false
|
||||||
|
if ((Get-Sha256 (Assert-RegularFile (
|
||||||
|
(Join-Path $modelRoot "rf_detr_large\1\model.plan")
|
||||||
|
) "RF-DETR TensorRT engine")) -cne "986399ce706b7380472cf5e473232249fed6e628971d8007f6609e83128d46b8") {
|
||||||
|
throw "RF-DETR TensorRT engine SHA-256 changed"
|
||||||
|
}
|
||||||
|
|
||||||
|
$image = "nvcr.io/nvidia/tritonserver:26.06-py3@sha256:58df7489c3f2276f9591d500a012dee03e23d35543ce3c390b4c001e6bf90794"
|
||||||
|
& docker image inspect $image *> $null
|
||||||
|
Assert-LastExitCode "Pinned M48T image inspection"
|
||||||
|
$historicalTriton = Get-Container "ndc-mission-core-triton"
|
||||||
|
if (-not $historicalTriton.State.Running -or $historicalTriton.State.Health.Status -cne "healthy") {
|
||||||
|
throw "Historical Triton must remain healthy during M48T quality evaluation"
|
||||||
|
}
|
||||||
|
$historicalTritonId = [string]$historicalTriton.Id
|
||||||
|
$tritonName = "ndc-mission-core-m48t-risk-quality-triton"
|
||||||
|
$qualityName = "ndc-mission-core-m48t-risk-quality"
|
||||||
|
foreach ($name in @($tritonName, $qualityName)) {
|
||||||
|
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||||
|
throw "M48T candidate container $name already exists"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$opencv = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived\perception-e3-opencv413092-v1\packages"
|
||||||
|
) "OpenCV dependency" $false
|
||||||
|
$pillow = Resolve-DDirectory (
|
||||||
|
"D:\NDC_MISSIONCORE\runtime\derived\perception-p0-env-v1"
|
||||||
|
) "Pillow dependency" $false
|
||||||
|
|
||||||
|
try {
|
||||||
|
& docker create `
|
||||||
|
--name $tritonName `
|
||||||
|
--read-only `
|
||||||
|
--security-opt "no-new-privileges:true" `
|
||||||
|
--cap-drop ALL `
|
||||||
|
--pids-limit 512 `
|
||||||
|
--shm-size 1g `
|
||||||
|
--gpus all `
|
||||||
|
--tmpfs "/tmp:rw,noexec,nosuid,size=2g" `
|
||||||
|
--health-cmd "curl --fail --silent http://127.0.0.1:8000/v2/health/ready" `
|
||||||
|
--health-interval 5s `
|
||||||
|
--health-timeout 3s `
|
||||||
|
--health-start-period 20s `
|
||||||
|
--health-retries 24 `
|
||||||
|
-v ((Convert-ToDockerPath $modelRoot) + ":/models:ro") `
|
||||||
|
$image `
|
||||||
|
tritonserver `
|
||||||
|
--model-repository=/models `
|
||||||
|
--model-control-mode=explicit `
|
||||||
|
--load-model=rf_detr_large `
|
||||||
|
--disable-auto-complete-config `
|
||||||
|
--strict-readiness=true `
|
||||||
|
--exit-on-error=true `
|
||||||
|
--allow-http=true `
|
||||||
|
--allow-grpc=false `
|
||||||
|
--allow-metrics=false *> $null
|
||||||
|
Assert-LastExitCode "M48T Triton creation"
|
||||||
|
& docker start $tritonName *> $null
|
||||||
|
Assert-LastExitCode "M48T Triton start"
|
||||||
|
$ready = $false
|
||||||
|
foreach ($attempt in 1..60) {
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
$candidate = Get-Container $tritonName
|
||||||
|
if (-not $candidate.State.Running) { throw "M48T Triton stopped during startup" }
|
||||||
|
if ($candidate.State.Health.Status -ceq "healthy") { $ready = $true; break }
|
||||||
|
}
|
||||||
|
if (-not $ready) { throw "M48T Triton did not become healthy" }
|
||||||
|
|
||||||
|
$maximumArguments = @()
|
||||||
|
if ($MaximumImages -gt 0) {
|
||||||
|
$maximumArguments = @("--maximum-images", ([string]$MaximumImages))
|
||||||
|
}
|
||||||
|
$arguments = @(
|
||||||
|
"run", "--name", $qualityName,
|
||||||
|
"--network", ("container:{0}" -f $tritonName),
|
||||||
|
"--read-only",
|
||||||
|
"--security-opt", "no-new-privileges:true",
|
||||||
|
"--cap-drop", "ALL",
|
||||||
|
"--pids-limit", "256",
|
||||||
|
"--gpus", "all",
|
||||||
|
"--tmpfs", "/tmp:rw,noexec,nosuid,size=2g",
|
||||||
|
"-e", "PYTHONDONTWRITEBYTECODE=1",
|
||||||
|
"-e", "PYTHONPATH=/release/nodedc_mission_core-0.1.0-py3-none-any.whl:/opt/opencv:/opt/pillow",
|
||||||
|
"-v", ((Convert-ToDockerPath $release) + ":/release:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $imagesRoot) + ":/dataset/val2017:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $annotations) + ":/dataset/instances_val2017.json:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $runOutput) + ":/output:rw"),
|
||||||
|
"-v", ((Convert-ToDockerPath $opencv) + ":/opt/opencv:ro"),
|
||||||
|
"-v", ((Convert-ToDockerPath $pillow) + ":/opt/pillow:ro"),
|
||||||
|
"--entrypoint", "python3",
|
||||||
|
$image,
|
||||||
|
"/release/run_m48t_coco_risk_quality_worker.py",
|
||||||
|
"--profile", "/release/m48t-risk-quality-temporal-v1.json",
|
||||||
|
"--annotations", "/dataset/instances_val2017.json",
|
||||||
|
"--images-root", "/dataset/val2017",
|
||||||
|
"--triton-origin", "http://127.0.0.1:8000",
|
||||||
|
"--output", "/output/result.json",
|
||||||
|
"--predictions", "/output/predictions.jsonl",
|
||||||
|
"--failures", "/output/failures.jsonl",
|
||||||
|
"--progress", "/output/progress.jsonl",
|
||||||
|
"--review-root", "/output/review",
|
||||||
|
"--runtime-artifact-sha256", $ExpectedWheelSha256,
|
||||||
|
"--runner-sha256", $runnerSha256,
|
||||||
|
"--images-archive-sha256", $imagesArchiveSha256,
|
||||||
|
"--annotations-document-sha256", $annotationsDocumentSha256
|
||||||
|
) + $maximumArguments
|
||||||
|
& docker @arguments
|
||||||
|
Assert-LastExitCode "M48T COCO risk quality evaluation"
|
||||||
|
if (-not (Test-Path -LiteralPath (Join-Path $runOutput "result.json") -PathType Leaf)) {
|
||||||
|
throw "M48T result was not written"
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
foreach ($name in @($qualityName, $tritonName)) {
|
||||||
|
if (& docker ps -a --format "{{.Names}}" --filter "name=^/$name$") {
|
||||||
|
& docker rm -f $name *> $null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$historicalAfter = Get-Container "ndc-mission-core-triton"
|
||||||
|
if (
|
||||||
|
$historicalAfter.Id -cne $historicalTritonId -or
|
||||||
|
-not $historicalAfter.State.Running -or
|
||||||
|
$historicalAfter.State.Health.Status -cne "healthy"
|
||||||
|
) {
|
||||||
|
throw "Historical Triton changed during M48T quality evaluation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output ("M48T_RESULT={0}" -f (Join-Path $runOutput "result.json"))
|
||||||
|
Write-Output ("COCO_IMAGES_SHA256={0}" -f $imagesArchiveSha256)
|
||||||
|
Write-Output ("COCO_ANNOTATIONS_SHA256={0}" -f $annotationsDocumentSha256)
|
||||||
|
Write-Output "HISTORICAL_TRITON_ACTION=none"
|
||||||
|
Write-Output "PRODUCTION_ACCEPTED=false"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.perception.m48t_risk_quality import (
|
||||||
|
CocoRiskImage,
|
||||||
|
M48TRiskQualityError,
|
||||||
|
RiskPrediction,
|
||||||
|
RiskTruth,
|
||||||
|
load_coco_risk_truth,
|
||||||
|
load_m48t_risk_quality_profile,
|
||||||
|
score_risk_quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48t-risk-quality-temporal-v1.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _truth(
|
||||||
|
annotation_id: int,
|
||||||
|
class_name: str,
|
||||||
|
family: str,
|
||||||
|
bbox: tuple[float, float, float, float],
|
||||||
|
*,
|
||||||
|
size_band: str = "medium",
|
||||||
|
) -> RiskTruth:
|
||||||
|
return RiskTruth(
|
||||||
|
image_id=1,
|
||||||
|
annotation_id=annotation_id,
|
||||||
|
class_name=class_name,
|
||||||
|
family=family,
|
||||||
|
bbox_xyxy=bbox,
|
||||||
|
projected_area_pixels=(bbox[2] - bbox[0]) * (bbox[3] - bbox[1]),
|
||||||
|
size_band=size_band,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prediction(
|
||||||
|
prediction_id: str,
|
||||||
|
class_name: str,
|
||||||
|
family: str,
|
||||||
|
bbox: tuple[float, float, float, float],
|
||||||
|
score: float = 0.9,
|
||||||
|
) -> RiskPrediction:
|
||||||
|
return RiskPrediction(
|
||||||
|
image_id=1,
|
||||||
|
prediction_id=prediction_id,
|
||||||
|
class_name=class_name,
|
||||||
|
family=family,
|
||||||
|
score=score,
|
||||||
|
bbox_xyxy=bbox,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_m48t_profile_pins_candidate_and_class_independent_temporal_policy() -> None:
|
||||||
|
profile = load_m48t_risk_quality_profile(PROFILE_PATH)
|
||||||
|
|
||||||
|
assert profile.minimum_score == 0.25
|
||||||
|
assert profile.model_id == "rf_detr_large:1"
|
||||||
|
assert profile.class_to_family["dog"] == "animal"
|
||||||
|
assert profile.temporal.initial_confirmation_observations == 2
|
||||||
|
assert profile.temporal.switch_confirmation_observations == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_m48t_profile_rejects_candidate_threshold_tuning(tmp_path: Path) -> None:
|
||||||
|
document = json.loads(PROFILE_PATH.read_text("utf-8"))
|
||||||
|
document["candidate"]["minimum_score"] = 0.2
|
||||||
|
changed = tmp_path / "changed.json"
|
||||||
|
changed.write_text(json.dumps(document), "utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(M48TRiskQualityError, match="tuned in place"):
|
||||||
|
load_m48t_risk_quality_profile(changed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_coco_truth_is_projected_to_actual_source_contract_and_filtered(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
profile = load_m48t_risk_quality_profile(PROFILE_PATH)
|
||||||
|
annotations = {
|
||||||
|
"images": [{"id": 1, "file_name": "one.jpg", "width": 400, "height": 300}],
|
||||||
|
"categories": [
|
||||||
|
{"id": index, "name": class_name}
|
||||||
|
for index, class_name in enumerate(profile.class_to_family, start=1)
|
||||||
|
],
|
||||||
|
"annotations": [
|
||||||
|
{"id": 1, "image_id": 1, "category_id": 1, "bbox": [10, 20, 20, 30], "iscrowd": 0},
|
||||||
|
{"id": 2, "image_id": 1, "category_id": 1, "bbox": [1, 1, 1, 1], "iscrowd": 0},
|
||||||
|
{"id": 3, "image_id": 1, "category_id": 1, "bbox": [10, 20, 20, 30], "iscrowd": 1},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
path = tmp_path / "instances.json"
|
||||||
|
path.write_text(json.dumps(annotations), "utf-8")
|
||||||
|
|
||||||
|
images, truth = load_coco_risk_truth(path, profile)
|
||||||
|
|
||||||
|
assert images == (CocoRiskImage(image_id=1, file_name="one.jpg", width=400, height=300),)
|
||||||
|
assert len(truth) == 1
|
||||||
|
assert truth[0].bbox_xyxy == (20.0, 40.0, 60.0, 100.0)
|
||||||
|
assert truth[0].projected_area_pixels == 2400.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_separates_exact_class_family_and_failure_buckets() -> None:
|
||||||
|
profile = load_m48t_risk_quality_profile(PROFILE_PATH)
|
||||||
|
images = (CocoRiskImage(image_id=1, file_name="one.jpg", width=800, height=600),)
|
||||||
|
truth = (
|
||||||
|
_truth(1, "person", "person", (10.0, 10.0, 110.0, 210.0), size_band="large"),
|
||||||
|
_truth(2, "dog", "animal", (200.0, 100.0, 260.0, 170.0)),
|
||||||
|
_truth(3, "car", "vehicle", (400.0, 200.0, 600.0, 350.0), size_band="large"),
|
||||||
|
_truth(4, "bicycle", "light-road-user", (650.0, 200.0, 760.0, 350.0)),
|
||||||
|
)
|
||||||
|
predictions = (
|
||||||
|
_prediction("p1", "person", "person", (10.0, 10.0, 110.0, 210.0)),
|
||||||
|
_prediction("p2", "cat", "animal", (200.0, 100.0, 260.0, 170.0)),
|
||||||
|
_prediction("p3", "car", "vehicle", (520.0, 300.0, 700.0, 450.0)),
|
||||||
|
_prediction("p4", "truck", "vehicle", (300.0, 20.0, 390.0, 100.0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = score_risk_quality(
|
||||||
|
images=images,
|
||||||
|
truth=truth,
|
||||||
|
predictions=predictions,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.report["counts"] == {
|
||||||
|
"predictions": 4,
|
||||||
|
"true_positive": 1,
|
||||||
|
"false_positive": 3,
|
||||||
|
"false_negative": 3,
|
||||||
|
"empty_prediction_risk_images": 0,
|
||||||
|
}
|
||||||
|
metrics = result.report["metrics"]
|
||||||
|
assert isinstance(metrics, dict)
|
||||||
|
families = metrics["families"]
|
||||||
|
assert isinstance(families, dict)
|
||||||
|
assert families["animal"]["exact_class_recall"] == 0.0
|
||||||
|
assert families["animal"]["family_recall"] == 1.0
|
||||||
|
buckets = result.report["failure_buckets"]
|
||||||
|
assert buckets["same-family-class-confusion"] == 1
|
||||||
|
assert buckets["localization"] == 1
|
||||||
|
assert buckets["missed"] == 1
|
||||||
|
assert buckets["unmatched-prediction"] == 3
|
||||||
|
assert result.report["quality_gates"]["passed"] is False
|
||||||
|
assert result.report["authority"]["candidate_accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_quality_rejects_predictions_below_frozen_threshold() -> None:
|
||||||
|
profile = load_m48t_risk_quality_profile(PROFILE_PATH)
|
||||||
|
images = (CocoRiskImage(image_id=1, file_name="one.jpg", width=800, height=600),)
|
||||||
|
|
||||||
|
with pytest.raises(M48TRiskQualityError, match="escaped the frozen score"):
|
||||||
|
score_risk_quality(
|
||||||
|
images=images,
|
||||||
|
truth=(_truth(1, "person", "person", (10.0, 10.0, 100.0, 200.0)),),
|
||||||
|
predictions=(
|
||||||
|
_prediction(
|
||||||
|
"p1",
|
||||||
|
"person",
|
||||||
|
"person",
|
||||||
|
(10.0, 10.0, 100.0, 200.0),
|
||||||
|
score=0.24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.perception.m48t_risk_quality import (
|
||||||
|
BoundedTemporalSemanticIdentity,
|
||||||
|
M48TRiskQualityError,
|
||||||
|
TemporalSemanticObservation,
|
||||||
|
load_m48t_risk_quality_profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PROFILE_PATH = REPOSITORY_ROOT / "config/perception/m48t-risk-quality-temporal-v1.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _observation(
|
||||||
|
time_ns: int,
|
||||||
|
raw_class: str | None,
|
||||||
|
*,
|
||||||
|
component_id: str = "temporal-000001",
|
||||||
|
currentness: str = "current",
|
||||||
|
) -> TemporalSemanticObservation:
|
||||||
|
return TemporalSemanticObservation(
|
||||||
|
component_id=component_id,
|
||||||
|
evidence_time_ns=time_ns,
|
||||||
|
raw_class_name=raw_class,
|
||||||
|
currentness=currentness,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_initial_class_requires_two_observations_and_identity_stays_geometry_owned() -> None:
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(load_m48t_risk_quality_profile(PROFILE_PATH))
|
||||||
|
|
||||||
|
first = stabilizer.update(_observation(0, "person"))
|
||||||
|
second = stabilizer.update(_observation(100_000_000, "person"))
|
||||||
|
|
||||||
|
assert first.resolution == "pending"
|
||||||
|
assert first.selected_class_name is None
|
||||||
|
assert second.resolution == "confirmed"
|
||||||
|
assert second.selected_class_name == "person"
|
||||||
|
assert second.component_id == "temporal-000001"
|
||||||
|
assert second.association_uses_semantic_class is False
|
||||||
|
assert second.occupancy_uses_semantic_class is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_family_switch_falls_back_unknown_until_third_confirmation() -> None:
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(load_m48t_risk_quality_profile(PROFILE_PATH))
|
||||||
|
stabilizer.update(_observation(0, "person"))
|
||||||
|
stabilizer.update(_observation(100_000_000, "person"))
|
||||||
|
|
||||||
|
first_conflict = stabilizer.update(_observation(200_000_000, "car"))
|
||||||
|
second_conflict = stabilizer.update(_observation(300_000_000, "car"))
|
||||||
|
switched = stabilizer.update(_observation(400_000_000, "car"))
|
||||||
|
|
||||||
|
assert first_conflict.resolution == "conflict"
|
||||||
|
assert first_conflict.selected_class_name is None
|
||||||
|
assert second_conflict.resolution == "conflict"
|
||||||
|
assert switched.resolution == "confirmed"
|
||||||
|
assert switched.selected_class_name == "car"
|
||||||
|
assert stabilizer.snapshot().class_switches == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_family_switch_holds_previous_class_until_confirmed() -> None:
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(load_m48t_risk_quality_profile(PROFILE_PATH))
|
||||||
|
stabilizer.update(_observation(0, "dog"))
|
||||||
|
stabilizer.update(_observation(100_000_000, "dog"))
|
||||||
|
|
||||||
|
pending = stabilizer.update(_observation(200_000_000, "cat"))
|
||||||
|
stabilizer.update(_observation(300_000_000, "cat"))
|
||||||
|
switched = stabilizer.update(_observation(400_000_000, "cat"))
|
||||||
|
|
||||||
|
assert pending.resolution == "pending"
|
||||||
|
assert pending.selected_class_name == "dog"
|
||||||
|
assert switched.selected_class_name == "cat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_semantic_hold_is_bounded_then_degrades_to_unknown() -> None:
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(load_m48t_risk_quality_profile(PROFILE_PATH))
|
||||||
|
stabilizer.update(_observation(0, "person"))
|
||||||
|
stabilizer.update(_observation(100_000_000, "person"))
|
||||||
|
|
||||||
|
held = stabilizer.update(_observation(300_000_000, None, currentness="held"))
|
||||||
|
unknown = stabilizer.update(_observation(500_000_001, None, currentness="held"))
|
||||||
|
|
||||||
|
assert held.resolution == "held"
|
||||||
|
assert held.selected_class_name == "person"
|
||||||
|
assert unknown.resolution == "unknown"
|
||||||
|
assert unknown.selected_class_name is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_expiry_removes_state_and_out_of_order_evidence_is_rejected() -> None:
|
||||||
|
stabilizer = BoundedTemporalSemanticIdentity(load_m48t_risk_quality_profile(PROFILE_PATH))
|
||||||
|
stabilizer.update(_observation(100, "person"))
|
||||||
|
with pytest.raises(M48TRiskQualityError, match="moved backwards"):
|
||||||
|
stabilizer.update(_observation(99, "person"))
|
||||||
|
|
||||||
|
expired = stabilizer.update(_observation(200, None, currentness="expired"))
|
||||||
|
restarted = stabilizer.update(_observation(300, "person"))
|
||||||
|
|
||||||
|
assert expired.resolution == "expired"
|
||||||
|
assert restarted.resolution == "pending"
|
||||||
|
assert stabilizer.snapshot().active_components == 1
|
||||||
Reference in New Issue
Block a user