feat(perception): restore camera-first semantic candidate

This commit is contained in:
DCCONSTRUCTIONS
2026-07-30 21:53:34 +03:00
parent fdcaf57ae7
commit e4e5bf6ea1
6 changed files with 1436 additions and 0 deletions
@@ -0,0 +1,119 @@
# LAB E52 · восстановление camera-first семантики
Дата: 2026-07-30
Статус: принят инженерный кандидат; runtime-модель не изменена и не
авторизована
## Причина
Операторская проверка E40 показала, что интерфейс выдавал фиксированную
`stratum-policy` за уверенность модели. В частности,
`geometry-only → occupied-environment` отображалось как решение модели с
уверенностью 100%. Из-за этого человек, тележка или часть фасада могли
выглядеть как ошибочно распознанный «фон/шум».
Это не было результатом рабочего camera-first контура. E29 по-прежнему
реализует исходное разделение ответственности:
- камера владеет классом и идентичностью объекта;
- LiDAR проверяет геометрическую поддержку и вычисляет дальность;
- LiDAR-кластер без camera-детекта не получает семантический класс.
E40 являлся историческим видимым мета-оценщиком и повторно решал presence по
фиксированным правилам поверх E29. E52 не переносит эти правила в runtime.
## Неизменяемые входы
- E29/E30:
`e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a`;
- E40:
`e40-perception-product-gate-e96eec9fd68c3ffaaee898d46285dd329191267200011680f084c75095b92e9a`;
- полная операторская ревизия E40: 23 из 23 случаев;
- camera candidate:
`result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`;
- camera source:
`recorded-camera-602ac89026ed12978619801d`, 4 489 кадров;
- Mask R-CNN:
`torchvision/maskrcnn_resnet50_fpn_v2`, COCO, threshold 0.5,
checkpoint SHA-256
`73cbd0190fcbe3ba339921fbce2c3a0b6bb9126c9a133c85e43a2a8e060a109e`.
Все входные SHA-256 проверены до и после E52. Исходные артефакты не
перезаписывались.
## Метод
E52 использует уже рассчитанные camera-детекты и выполняет только лёгкое
source-bound сопоставление:
1. берёт 20 случаев, где оператор подтвердил ошибку представления E40;
2. отбрасывает camera-боксы больше 25% изображения как патологические;
3. для `geometry-only` требует не меньше четырёх выбранных LiDAR-точек;
4. camera-бокс должен покрывать не меньше 70% точек выбранного кластера;
5. класс берётся только из camera-детекта;
6. дальность вычисляется как медиана глубин связанных LiDAR-точек;
7. нерешённый кластер остаётся геометрией без класса.
Для исходных camera-observation используется camera-box IoU не ниже 0.25.
Группы `car/bus/truck` сравниваются как транспорт, но исходная camera-метка
сохраняется в evidence.
## Результат
```text
e52-camera-first-restoration-37ea5269a7f49fc84cbe2c394682f30ccc0d06b781d7d734c2f5420ba764b09f
```
| Измерение | Результат |
| --- | ---: |
| Подтверждённые оператором случаи | 20 |
| Подтверждённые `geometry-only` | 8 |
| Восстановлена camera-семантика + LiDAR-дальность | 3 |
| Намеренно оставлены нерешёнными | 5 |
| Pathological full-frame box принят | 0 |
Восстановленные случаи:
| Кадр | Camera-класс | Camera score | Покрытие LiDAR | LiDAR range |
| ---: | --- | ---: | ---: | ---: |
| 167 | truck | 0.979998 | 15/15 | 8.038404 m |
| 259 | person | 0.999834 | 49/50 | 0.512825 m |
| 3181 | car | 0.878104 | 4/4 | 0.301412 m |
Кадр 259 подтверждает исходную проблему: YOLOX не дал семантического
наблюдения, однако уже рассчитанный camera candidate уверенно выделил человека,
а LiDAR обеспечил независимую метрическую дальность.
Пять случаев не были автоматически «исправлены»:
- кадры 1270, 1878 и 2535 имеют только 1–2 выбранные LiDAR-точки;
- на кадрах 267 и 1080 пересечение давал патологический бокс почти на весь
кадр;
- E52 не повышает такие случаи до объектов и не выдаёт отсутствие точек за
свободное пространство.
## Решение
Camera-first принцип восстановлен в отдельном инженерном кандидате:
```text
camera detection
→ проверка LiDAR-точек внутри camera bbox
→ camera semantic + LiDAR range
```
Runtime E29, sealed E37E40, K1-команды и persistent map не изменены. Общий
Mask R-CNN пока не принят как production detector.
Следующий gate — прогнать уже рассчитанные детекты через неизменённую
ассоциацию E29 в полном source-bound shadow-контуре и измерить:
- восстановление camera-семантики;
- отбрасывание больших ложных боксов;
- временную стабильность;
- задержку и ресурсную стоимость;
- отсутствие регрессии текущих корректных camera-first объектов.
Только после отдельного явного принятия этого gate кандидат может заменить или
дополнить YOLOX в runtime.
@@ -0,0 +1,35 @@
{
"schema_version": "missioncore.e52-camera-first-restoration-profile/v1",
"profile_id": "e52-ravnoves00-camera-first-restoration/v1",
"expected_e30_materialization_id": "e30-materialization-841af926d8d28ab93538c46d8f31278a2234c4d1c12c7dc4dc296b249d59735a",
"expected_e40_result_id": "e40-perception-product-gate-e96eec9fd68c3ffaaee898d46285dd329191267200011680f084c75095b92e9a",
"expected_detector_result_id": "result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02",
"expected_camera_job_id": "recorded-camera-602ac89026ed12978619801d",
"target_labels": [
"person",
"bicycle",
"car",
"motorcycle",
"bus",
"truck"
],
"label_groups": {
"person": [
"person"
],
"two-wheel": [
"bicycle",
"motorcycle"
],
"vehicle": [
"car",
"bus",
"truck"
]
},
"minimum_detector_score": 0.5,
"maximum_bbox_image_fraction": 0.25,
"minimum_selected_lidar_points": 4,
"minimum_selected_lidar_coverage": 0.7,
"minimum_original_bbox_iou": 0.25
}
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Build the immutable E52 camera-first restoration candidate."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from k1link.compute.e52_camera_first_restoration import (
build_e52_camera_first_restoration,
)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--e30-materialization", type=Path, required=True)
parser.add_argument("--e40-result", type=Path, required=True)
parser.add_argument("--operator-review", type=Path, required=True)
parser.add_argument("--detector-result", type=Path, required=True)
parser.add_argument("--profile", type=Path, required=True)
parser.add_argument("--output-root", type=Path, required=True)
args = parser.parse_args()
result = build_e52_camera_first_restoration(
e30_materialization_root=args.e30_materialization,
e40_result_root=args.e40_result,
operator_review_path=args.operator_review,
detector_result_root=args.detector_result,
profile_path=args.profile,
output_root=args.output_root,
)
print(
json.dumps(
{
"result_id": result.result_id,
"result_root": str(result.result_root),
"metrics": result.report["metrics"],
"decision": result.report["decision"],
},
ensure_ascii=False,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+20
View File
@@ -52,6 +52,17 @@ from .e51_motion_semantic_qualification import (
derive_motion_semantic_signal,
read_e51_motion_semantic_qualification,
)
from .e52_camera_first_restoration import (
E52_CASE_SCHEMA,
E52_PROFILE_SCHEMA,
E52_REPORT_SCHEMA,
E52_RESULT_SCHEMA,
E52CameraFirstRestoration,
E52CameraFirstRestorationError,
build_e52_camera_first_restoration,
evaluate_camera_first_case,
read_e52_camera_first_restoration,
)
from .evaluation_pack import (
ANNOTATION_CONTRACT_SCHEMA,
EVALUATION_PACK_SCHEMA,
@@ -419,6 +430,15 @@ __all__ = [
"build_e51_motion_semantic_qualification",
"derive_motion_semantic_signal",
"read_e51_motion_semantic_qualification",
"E52_CASE_SCHEMA",
"E52_PROFILE_SCHEMA",
"E52_REPORT_SCHEMA",
"E52_RESULT_SCHEMA",
"E52CameraFirstRestoration",
"E52CameraFirstRestorationError",
"build_e52_camera_first_restoration",
"evaluate_camera_first_case",
"read_e52_camera_first_restoration",
"build_lidar_ground_annotation_template",
"build_lidar_ground_benchmark",
"build_k1_local_surface",
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
from __future__ import annotations
from typing import Any
import numpy as np
from k1link.compute.e52_camera_first_restoration import (
E52_CASE_SCHEMA,
evaluate_camera_first_case,
)
PROFILE: dict[str, Any] = {
"target_labels": ["person", "car", "bus", "truck"],
"label_groups": {
"person": ["person"],
"vehicle": ["car", "bus", "truck"],
},
"minimum_detector_score": 0.5,
"maximum_bbox_image_fraction": 0.25,
"minimum_selected_lidar_points": 4,
"minimum_selected_lidar_coverage": 0.7,
"minimum_original_bbox_iou": 0.25,
}
def _item(*, stratum: str, snapshot: dict[str, Any]) -> dict[str, Any]:
return {
"item_id": "e30-review-item-" + "a" * 64,
"stratum": stratum,
"evidence_binding": {"source_frame_index": 259},
"materialization": {
"projection_width": 800,
"projection_height": 600,
},
"e29_snapshot": snapshot,
}
def _case(*, stratum: str) -> dict[str, Any]:
return {
"sequence": 7,
"source_stratum": stratum,
"prediction": {
"presence": "occupied-environment",
"geometry_association": "independent-occupied",
"freshness": "current",
},
"reference": {
"presence": "object-present",
"geometry_association": "object-associated",
"freshness": "current",
},
}
def _frame(instances: list[dict[str, Any]]) -> dict[str, Any]:
return {
"schema_version": "missioncore.panoptic-frame/v1",
"frame_index": 259,
"instances": instances,
}
def test_e52_restores_camera_semantic_then_derives_lidar_range() -> None:
pixels = np.asarray([[150.0, 225.0], [152.0, 230.0], [155.0, 235.0], [160.0, 240.0]])
depths = np.asarray([4.0, 4.2, 4.4, 4.6])
result = evaluate_camera_first_case(
item=_item(
stratum="geometry-only",
snapshot={
"geometry_status": "single-source-geometry",
"nearest_range_m": 3.9,
},
),
e40_case=_case(stratum="geometry-only"),
detector_frame=_frame(
[
{
"instance_id": 1,
"label": "person",
"score": 0.99,
"box_xyxy": [140.0, 210.0, 220.0, 450.0],
}
]
),
projected_pixels_xy=pixels,
projected_depth_m=depths,
projected_selected_mask=np.ones(4, dtype=np.uint8),
profile=PROFILE,
)
assert result["schema_version"] == E52_CASE_SCHEMA
assert result["restoration"] == {
"state": "camera-semantic-restored-with-lidar-range",
"semantic_owner": "camera",
"semantic_label": "person",
"metric_geometry_owner": "lidar",
"range_m": 4.3,
"runtime_publishable": False,
"requires_shadow_acceptance": True,
}
def test_e52_rejects_pathological_full_frame_detector_box() -> None:
pixels = np.asarray([[150.0, 225.0], [152.0, 230.0], [155.0, 235.0], [160.0, 240.0]])
result = evaluate_camera_first_case(
item=_item(
stratum="geometry-only",
snapshot={"geometry_status": "single-source-geometry"},
),
e40_case=_case(stratum="geometry-only"),
detector_frame=_frame(
[
{
"instance_id": 1,
"label": "car",
"score": 0.9,
"box_xyxy": [0.0, 0.0, 800.0, 600.0],
}
]
),
projected_pixels_xy=pixels,
projected_depth_m=np.asarray([4.0, 4.2, 4.4, 4.6]),
projected_selected_mask=np.ones(4, dtype=np.uint8),
profile=PROFILE,
)
assert result["candidate_camera"]["selected"] is None
assert result["candidate_camera"]["rejected_pathological_large_box_count"] == 1
assert result["restoration"]["state"] == "unresolved-no-camera-semantic"
def test_e52_corroborates_existing_camera_semantic_without_inventing_range() -> None:
result = evaluate_camera_first_case(
item=_item(
stratum="camera-only",
snapshot={
"label": "bus",
"score": 0.4,
"bbox_xyxy": [100.0, 100.0, 200.0, 200.0],
"geometry_status": "camera-only",
"range_m": None,
},
),
e40_case=_case(stratum="camera-only"),
detector_frame=_frame(
[
{
"instance_id": 4,
"label": "truck",
"score": 0.95,
"box_xyxy": [105.0, 105.0, 205.0, 205.0],
}
]
),
projected_pixels_xy=np.empty((0, 2), dtype=np.float64),
projected_depth_m=np.empty(0, dtype=np.float64),
projected_selected_mask=np.empty(0, dtype=np.uint8),
profile=PROFILE,
)
assert result["restoration"]["state"] == "camera-semantic-corroborated-no-lidar-range"
assert result["restoration"]["semantic_label"] == "truck"
assert result["restoration"]["range_m"] is None