feat: add local surface review triage
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user