feat: add local surface review triage

This commit is contained in:
DCCONSTRUCTIONS
2026-07-26 00:11:42 +03:00
parent 3449b2bc3e
commit c6c48fbc38
12 changed files with 1273 additions and 33 deletions
+2
View File
@@ -110,6 +110,7 @@ from .lidar_local_surface import (
DEFAULT_K1_LOCAL_SURFACE_PROFILE,
K1_LOCAL_SURFACE_FRAME_SCHEMA,
K1_LOCAL_SURFACE_REPORT_SCHEMA,
K1_LOCAL_SURFACE_REVIEW_SCHEMA,
K1_LOCAL_SURFACE_SCHEMA,
K1_LOCAL_SURFACE_TIMELINE_SCHEMA,
K1LocalSurfaceProfile,
@@ -209,6 +210,7 @@ __all__ = [
"LIDAR_GROUND_FRAME_SCHEMA",
"K1_LOCAL_SURFACE_FRAME_SCHEMA",
"K1_LOCAL_SURFACE_REPORT_SCHEMA",
"K1_LOCAL_SURFACE_REVIEW_SCHEMA",
"K1_LOCAL_SURFACE_SCHEMA",
"K1_LOCAL_SURFACE_TIMELINE_SCHEMA",
"LIDAR_FIELD_REVIEW_REPORT_SCHEMA",
+206 -1
View File
@@ -11,7 +11,7 @@ from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Final
from typing import Any, Final, cast
import numpy as np
import numpy.typing as npt
@@ -29,9 +29,14 @@ K1_LOCAL_SURFACE_SCHEMA: Final = "missioncore.k1-local-surface/v1"
K1_LOCAL_SURFACE_REPORT_SCHEMA: Final = "missioncore.k1-local-surface-report/v1"
K1_LOCAL_SURFACE_FRAME_SCHEMA: Final = "missioncore.k1-local-surface-frame/v1"
K1_LOCAL_SURFACE_TIMELINE_SCHEMA: Final = "missioncore.k1-local-surface-timeline/v1"
K1_LOCAL_SURFACE_REVIEW_SCHEMA: Final = "missioncore.k1-local-surface-review/v1"
K1_LOCAL_SURFACE_ARRAYS_NAME: Final = "local-surface.npz"
K1_LOCAL_SURFACE_REPORT_NAME: Final = "local-surface.json"
K1_LOCAL_SURFACE_MANIFEST_NAME: Final = "manifest.json"
K1_LOCAL_SURFACE_REVIEW_PROFILE_ID: Final = "missioncore-local-surface-attention/v1"
K1_LOCAL_SURFACE_REVIEW_TAIL_M: Final = 0.45
K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR: Final = 0.85
K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE: Final = 2.0
POINT_UNCLASSIFIED: Final = 0
POINT_SURFACE: Final = 1
@@ -631,6 +636,195 @@ class K1LocalSurfaceV1:
"authority": self.report["authority"],
}
def review_detail(self, source: E10LidarFieldSource) -> dict[str, object]:
_validate_source_binding(self, source)
criteria = self._review_criteria()
reason_counts = {
"prediction-tail": 0,
"prediction-inlier-drop": 0,
"surface-height-jump": 0,
"surface-slope-jump": 0,
"surface-roughness-jump": 0,
}
if not self.has_temporal_qualification:
return {
"schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA,
"review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID,
"model_id": self.model_id,
"source_pack_id": source.pack_id,
"session_id": source.identity["session_id"],
"available": False,
"criteria": criteria,
"summary": {
"item_count": 0,
"episode_count": 0,
"high_attention_count": 0,
"review_attention_count": 0,
"reason_counts": reason_counts,
},
"items": [],
"ground_truth": False,
"access": "read-only",
"authority": self.report["authority"],
}
tail_threshold = float(criteria["prediction_tail_residual_p95_m"])
inlier_floor = float(criteria["prediction_inlier_fraction_floor"])
height_threshold = float(criteria["surface_height_jump_m"])
slope_threshold = float(criteria["surface_slope_jump_deg"])
roughness_threshold = float(criteria["surface_roughness_jump_m"])
chronological: list[dict[str, object]] = []
last_review_frame: int | None = None
episode_index = 0
for frame_index in range(source.frame_count):
reasons: list[str] = []
ratios: list[float] = []
prediction_available = bool(
self.arrays["prediction_available"][frame_index]
)
prediction_p95 = float(
self.arrays["prediction_residual_p95_m"][frame_index]
)
prediction_inlier = float(
self.arrays["prediction_inlier_fraction"][frame_index]
)
if prediction_available and prediction_p95 >= tail_threshold:
reasons.append("prediction-tail")
ratios.append(prediction_p95 / tail_threshold)
if prediction_available and prediction_inlier < inlier_floor:
reasons.append("prediction-inlier-drop")
ratios.append((1.0 - prediction_inlier) / (1.0 - inlier_floor))
temporal_compared = bool(self.arrays["temporal_compared"][frame_index])
height_delta = float(self.arrays["height_delta_m"][frame_index])
slope_delta = float(self.arrays["slope_delta_deg"][frame_index])
roughness_delta = float(self.arrays["roughness_delta_m"][frame_index])
if temporal_compared and height_delta >= height_threshold:
reasons.append("surface-height-jump")
ratios.append(height_delta / height_threshold)
if temporal_compared and slope_delta >= slope_threshold:
reasons.append("surface-slope-jump")
ratios.append(slope_delta / slope_threshold)
if temporal_compared and roughness_delta >= roughness_threshold:
reasons.append("surface-roughness-jump")
ratios.append(roughness_delta / roughness_threshold)
if not reasons:
continue
if last_review_frame is None or frame_index - last_review_frame > 2:
episode_index += 1
last_review_frame = frame_index
for reason in reasons:
reason_counts[reason] += 1
score = max(ratios)
chronological.append(
{
"rank": 0,
"frame_index": frame_index,
"source_frame_index": int(
source.arrays["source_frame_indices"][frame_index]
),
"session_seconds": float(
source.arrays["session_seconds"][frame_index]
),
"episode_id": f"episode-{episode_index:02d}",
"attention": (
"high"
if score >= K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE
else "review"
),
"attention_score": score,
"reasons": reasons,
"prediction": {
"available": prediction_available,
"residual_p50_m": float(
self.arrays["prediction_residual_p50_m"][frame_index]
),
"residual_p95_m": prediction_p95,
"inlier_fraction": prediction_inlier,
},
"temporal": {
"compared": temporal_compared,
"height_delta_m": height_delta,
"slope_delta_deg": slope_delta,
"roughness_delta_m": roughness_delta,
},
"surface": {
"sensor_height_m": float(
self.arrays["sensor_height_m"][frame_index]
),
"slope_deg": float(self.arrays["slope_deg"][frame_index]),
"roughness_m": float(
self.arrays["roughness_m"][frame_index]
),
"confidence": float(
self.arrays["confidence"][frame_index]
),
},
"step_candidate_point_count": int(
self.arrays["step_candidate_point_count"][frame_index]
),
}
)
items = sorted(
chronological,
key=lambda item: (
-cast(float, item["attention_score"]),
cast(int, item["frame_index"]),
),
)
for rank, item in enumerate(items, start=1):
item["rank"] = rank
high_attention_count = sum(
item["attention"] == "high" for item in items
)
return {
"schema_version": K1_LOCAL_SURFACE_REVIEW_SCHEMA,
"review_profile_id": K1_LOCAL_SURFACE_REVIEW_PROFILE_ID,
"model_id": self.model_id,
"source_pack_id": source.pack_id,
"session_id": source.identity["session_id"],
"available": True,
"criteria": criteria,
"summary": {
"item_count": len(items),
"episode_count": episode_index,
"high_attention_count": high_attention_count,
"review_attention_count": len(items) - high_attention_count,
"reason_counts": reason_counts,
},
"items": items,
"ground_truth": False,
"access": "read-only",
"authority": self.report["authority"],
}
def _review_criteria(self) -> dict[str, float | int]:
profile = _object(self.identity.get("profile"), "K1 local-surface profile")
temporal = _object(
profile.get("temporal_qualification"),
"K1 local-surface temporal profile",
)
return {
"prediction_tail_residual_p95_m": K1_LOCAL_SURFACE_REVIEW_TAIL_M,
"prediction_inlier_fraction_floor": (
K1_LOCAL_SURFACE_REVIEW_INLIER_FLOOR
),
"surface_height_jump_m": _positive_number(
temporal.get("height_jump_m"),
"K1 local-surface height jump threshold",
),
"surface_slope_jump_deg": _positive_number(
temporal.get("slope_jump_deg"),
"K1 local-surface slope jump threshold",
),
"surface_roughness_jump_m": _positive_number(
temporal.get("roughness_jump_m"),
"K1 local-surface roughness jump threshold",
),
"high_attention_score": K1_LOCAL_SURFACE_REVIEW_HIGH_SCORE,
"episode_max_frame_gap": 2,
}
def build_k1_local_surface(
source: E10LidarFieldSource,
@@ -1479,6 +1673,17 @@ def _nonnegative_int(value: object, label: str) -> int:
return value
def _positive_number(value: object, label: str) -> float:
if (
not isinstance(value, (int, float))
or isinstance(value, bool)
or not math.isfinite(value)
or value <= 0.0
):
raise LidarGroundError(f"{label} is invalid")
return float(value)
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
+55
View File
@@ -600,6 +600,61 @@ def build_lidar_router(
detail="K1 local-surface timeline не прошёл проверку целостности",
) from exc
@router.get("/local-surfaces/{model_id}/review")
def get_k1_local_surface_review(model_id: str) -> dict[str, object]:
if _LOCAL_SURFACE_ID.fullmatch(model_id) is None:
raise HTTPException(
status_code=404,
detail="K1 local-surface review не найден",
)
model_root = local_surface_root_provider()
source_root = e10_source_root_provider()
if model_root is None or not model_root.is_dir():
raise HTTPException(
status_code=503,
detail="K1 local-surface storage не настроен",
)
if source_root is None or not source_root.is_dir():
raise HTTPException(
status_code=503,
detail="E10 LiDAR source storage не настроен",
)
model_path = model_root / model_id
if not model_path.is_dir():
raise HTTPException(
status_code=404,
detail="K1 local-surface model не найден",
)
try:
model = K1LocalSurfaceV1(model_path)
try:
source_pack_id = model.identity.get("source_pack_id")
if (
not isinstance(source_pack_id, str)
or _E10_PACK_ID.fullmatch(source_pack_id) is None
):
raise LidarGroundError("K1 local-surface source id is invalid")
source_path = source_root / source_pack_id
if not source_path.is_dir():
raise HTTPException(
status_code=404,
detail="Связанный E10 LiDAR source не найден",
)
source = E10LidarFieldSource(source_path)
try:
return model.review_detail(source)
finally:
source.close()
finally:
model.close()
except HTTPException:
raise
except (LidarGroundError, OSError) as exc:
raise HTTPException(
status_code=409,
detail="K1 local-surface review не прошёл проверку целостности",
) from exc
@router.get("/local-surfaces/{model_id}/frames/{frame_index}")
def get_k1_local_surface_frame(
model_id: str,