feat(perception): add full camera-first shadow
This commit is contained in:
@@ -415,6 +415,13 @@ Dataset expansion is no longer the next gate.
|
|||||||
- [x] Add a separate diagnostic motion-observation and near-occupied proximity
|
- [x] Add a separate diagnostic motion-observation and near-occupied proximity
|
||||||
derivative with temporal freshness, source provenance and no persistent-map
|
derivative with temporal freshness, source provenance and no persistent-map
|
||||||
mutation.
|
mutation.
|
||||||
|
- [x] Run the candidate camera detector through the canonical E29 association
|
||||||
|
over all 4,489 RAVNOVES00 frames. Keep camera semantic ownership, publish
|
||||||
|
LiDAR range only from qualified occupied support and retain the detector as
|
||||||
|
a diagnostic shadow pending independent quality review.
|
||||||
|
- [x] Version the vehicle-body/LiDAR-mount contract and bind RAVNOVES00
|
||||||
|
fail-closed as a portable, physically unbound K1 source without invented
|
||||||
|
dimensions or transforms.
|
||||||
- [ ] Admit a `recent-collision` state only after vehicle-body and LiDAR-mount
|
- [ ] Admit a `recent-collision` state only after vehicle-body and LiDAR-mount
|
||||||
geometry are bound; proximity is not collision truth.
|
geometry are bound; proximity is not collision truth.
|
||||||
- [x] Reuse the accepted camera-to-LiDAR projection as an optional semantic
|
- [x] Reuse the accepted camera-to-LiDAR projection as an optional semantic
|
||||||
|
|||||||
@@ -176,6 +176,13 @@ real authority.
|
|||||||
- [x] Verify strict round-trip plus adversarial duplicate ownership, unknown
|
- [x] Verify strict round-trip plus adversarial duplicate ownership, unknown
|
||||||
owner, camera-only/conflict range, geometry-only semantics, held/persistent
|
owner, camera-only/conflict range, geometry-only semantics, held/persistent
|
||||||
ownership, unavailable source and authority mutation cases.
|
ownership, unavailable source and authority mutation cases.
|
||||||
|
- [x] Reuse the canonical E29 frame association in E53 over all 4,489
|
||||||
|
RAVNOVES00 frames with a second camera candidate. Preserve camera semantic
|
||||||
|
ownership, keep unavailable LiDAR range explicit and reject runtime
|
||||||
|
promotion without independent truth.
|
||||||
|
- [x] Add `missioncore.rig-geometry/v1` with strict unbound/measured/qualified
|
||||||
|
states. Bind the portable RAVNOVES00 K1 as `unbound`; do not infer a vehicle
|
||||||
|
body or LiDAR-to-body mount from the recording.
|
||||||
|
|
||||||
The frozen A3 engineering and human generations cover all 486 items and
|
The frozen A3 engineering and human generations cover all 486 items and
|
||||||
retains the exact A2 materialization/review-pack and 42-sheet evidence
|
retains the exact A2 materialization/review-pack and 42-sheet evidence
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# ADR 0035: Camera-first shadow and physical rig-geometry boundary
|
||||||
|
|
||||||
|
Date: 2026-07-30
|
||||||
|
Status: accepted and implemented
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Mission Core preserves one perception ownership rule:
|
||||||
|
|
||||||
|
```text
|
||||||
|
camera owns semantic class and object proposal
|
||||||
|
LiDAR owns metric support and range
|
||||||
|
```
|
||||||
|
|
||||||
|
The canonical E29 association is exposed as one reusable frame evaluator and
|
||||||
|
is reused by E29 and full-source candidate shadows. A camera proposal remains
|
||||||
|
an object when LiDAR support is unavailable; its range remains unavailable.
|
||||||
|
Unassociated LiDAR geometry remains geometry without a semantic class.
|
||||||
|
|
||||||
|
E53 runs a candidate camera detector over every RAVNOVES00 frame through that
|
||||||
|
unchanged association. It is a diagnostic shadow and cannot mutate E29,
|
||||||
|
persistent reconstruction or runtime detector selection.
|
||||||
|
|
||||||
|
## Rig geometry
|
||||||
|
|
||||||
|
Vehicle-body and LiDAR mount/extrinsic use
|
||||||
|
`missioncore.rig-geometry/v1`. The contract has three states:
|
||||||
|
|
||||||
|
- `unbound`: no physical body or mount values may be present;
|
||||||
|
- `measured`: body and rigid transform require immutable measurement evidence
|
||||||
|
and uncertainty, but collision remains unavailable;
|
||||||
|
- `qualified`: geometry passed its named qualification method, while
|
||||||
|
collision still requires a separate algorithm and safety gate.
|
||||||
|
|
||||||
|
RAVNOVES00 binds `ravnoves00-portable-k1/unbound-v1`. It is portable K1
|
||||||
|
evidence and supplies no vehicle body or measured LiDAR→body transform.
|
||||||
|
Invented dimensions, sensor height or identity transforms are rejected.
|
||||||
|
|
||||||
|
## Resource correction
|
||||||
|
|
||||||
|
The first E53 full pass exposed repeated decompression of
|
||||||
|
`cloud_points_map` inside the frame loop. That immutable result failed its
|
||||||
|
50 ms p95 gate at 210.7093 ms.
|
||||||
|
|
||||||
|
The accepted implementation materializes compressed source members once
|
||||||
|
before sequential frame processing. The corrected run processed all 4 489
|
||||||
|
frames at 1.7993 ms p95 and 36.8594 MiB peak-RSS growth. No Docker, worker,
|
||||||
|
network service or second Mission Core backend participates in the path.
|
||||||
|
|
||||||
|
## Authority
|
||||||
|
|
||||||
|
E53 proves technical full-source execution, not detector accuracy:
|
||||||
|
|
||||||
|
- E29 overlap is comparison, not ground truth;
|
||||||
|
- adjacent image-space IoU is continuity diagnostics, not tracking truth;
|
||||||
|
- the candidate detector is not operationally promoted;
|
||||||
|
- collision, navigation, safety and commands remain false.
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# LAB E53 · полный camera-first shadow
|
||||||
|
|
||||||
|
Дата: 2026-07-30
|
||||||
|
|
||||||
|
Статус: принят технический диагностический shadow; camera detector не
|
||||||
|
повышен до operational runtime
|
||||||
|
|
||||||
|
## Цель
|
||||||
|
|
||||||
|
Проверить на всех 4 489 кадрах RAVNOVES00 восстановленный архитектурный
|
||||||
|
принцип:
|
||||||
|
|
||||||
|
```text
|
||||||
|
camera detection and semantic class
|
||||||
|
→ canonical E29 camera↔LiDAR association
|
||||||
|
→ LiDAR metric support and range
|
||||||
|
```
|
||||||
|
|
||||||
|
LiDAR не создаёт семантический класс. Camera-объект без квалифицированной
|
||||||
|
LiDAR-поддержки остаётся camera-only объектом без выдуманной дальности.
|
||||||
|
|
||||||
|
## Неизменяемые входы
|
||||||
|
|
||||||
|
- E10 LiDAR pack:
|
||||||
|
`e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b`;
|
||||||
|
- local surface:
|
||||||
|
`k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55`;
|
||||||
|
- camera candidate:
|
||||||
|
`result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02`;
|
||||||
|
- accepted E29:
|
||||||
|
`e29-camera-geometry-421a9d930638bef12cd5eb10979a477917fa4a389e655ed95f73ba4bd62e13dc`;
|
||||||
|
- rig geometry:
|
||||||
|
`ravnoves00-portable-k1/unbound-v1`.
|
||||||
|
|
||||||
|
Rig profile намеренно не содержит корпуса, трансформации LiDAR→body или
|
||||||
|
физических размеров: RAVNOVES00 записан переносным K1 и не доказывает
|
||||||
|
установку на транспортное средство.
|
||||||
|
|
||||||
|
## Метод
|
||||||
|
|
||||||
|
E53 потоково читает detector и E29 metadata, один раз материализует compressed
|
||||||
|
source arrays и для каждого source-aligned кадра:
|
||||||
|
|
||||||
|
1. оставляет camera-классы `person`, `bicycle`, `car`, `motorcycle`, `bus`,
|
||||||
|
`truck` с score не ниже 0.5;
|
||||||
|
2. отбрасывает bbox больше 25% изображения;
|
||||||
|
3. применяет без изменения профиль
|
||||||
|
`camera-first-local-surface-validation/v1`;
|
||||||
|
4. сохраняет camera semantic ownership;
|
||||||
|
5. публикует LiDAR range только при связанном occupied support;
|
||||||
|
6. измеряет adjacent-frame family+IoU continuity и source-bound overlap с E29;
|
||||||
|
7. оставляет collision unavailable.
|
||||||
|
|
||||||
|
Сравнение с E29 и adjacent IoU не являются ground truth или persistent
|
||||||
|
tracking truth.
|
||||||
|
|
||||||
|
## Отвергнутый первый проход
|
||||||
|
|
||||||
|
Первый immutable результат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
e53-camera-first-shadow-94eb57cce8dbfb0ea679486ed55aec32c2f38c93ae67e1c606787587cd3a0da5
|
||||||
|
```
|
||||||
|
|
||||||
|
Он обработал все 4 489 кадров, но был отвергнут: p95 составил 210.7093 ms
|
||||||
|
против заранее заданных 50 ms. Причина — обращение к compressed NPZ member
|
||||||
|
`cloud_points_map` внутри frame loop, из-за чего массив 9.2 млн точек
|
||||||
|
распаковывался повторно.
|
||||||
|
|
||||||
|
Исправление не меняло входы, пороги или ассоциацию: source arrays
|
||||||
|
материализуются один раз до цикла.
|
||||||
|
|
||||||
|
## Принятый результат
|
||||||
|
|
||||||
|
```text
|
||||||
|
e53-camera-first-shadow-e6f03cf8bfb15db86100239b060e13f914532618b7b99e811866e4e6a555186c
|
||||||
|
```
|
||||||
|
|
||||||
|
| Измерение | Результат |
|
||||||
|
| --- | ---: |
|
||||||
|
| Кадры | 4 489 / 4 489 |
|
||||||
|
| Кадры с LiDAR | 3 928 |
|
||||||
|
| Кадры с camera candidates | 4 471 |
|
||||||
|
| Принятые camera candidates | 48 944 |
|
||||||
|
| Pathological large boxes отклонено | 946 |
|
||||||
|
| Camera + LiDAR agree | 7 686 |
|
||||||
|
| Camera-only | 34 940 |
|
||||||
|
| Source unavailable | 6 318 |
|
||||||
|
| Объекты с LiDAR range | 10 183 |
|
||||||
|
| Доля объектов с range | 20.8054% |
|
||||||
|
| E29 current family+IoU overlap | 18 199 / 19 625 = 92.7338% |
|
||||||
|
| Adjacent family+IoU continuity | 31 476 / 48 933 = 64.3247% |
|
||||||
|
| Frame latency p50 / p95 / max | 0.9881 / 1.7993 / 9.4521 ms |
|
||||||
|
| Полный elapsed | 4 962.34 ms |
|
||||||
|
| Peak-RSS growth | 36.8594 MiB |
|
||||||
|
|
||||||
|
Распределение camera candidates: 44 498 `car`, 2 341 `person`, 1 876
|
||||||
|
`truck`, 142 `bicycle`, 66 `motorcycle`, 21 `bus`.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Полный source-bound camera-first shadow технически принят:
|
||||||
|
|
||||||
|
- accounting и binding закрыты;
|
||||||
|
- E29 association переиспользована;
|
||||||
|
- latency и memory gate пройдены;
|
||||||
|
- camera semantic ownership сохранён;
|
||||||
|
- LiDAR-native class, false free space и collision не публикуются.
|
||||||
|
|
||||||
|
Mask R-CNN не принят как operational detector. Большое число camera candidates,
|
||||||
|
64.32% adjacent continuity и 92.73% overlap с E29 требуют независимой
|
||||||
|
source-bound quality review. E29 также не является ground truth.
|
||||||
|
|
||||||
|
Physical vehicle-body и LiDAR mount/extrinsic остаются отдельным измерительным
|
||||||
|
gate. До их квалификации `recent-collision`, navigation, safety и commands
|
||||||
|
остаются недоступны.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.e53-camera-first-shadow-profile/v1",
|
||||||
|
"profile_id": "e53-ravnoves00-camera-first-full-shadow/v1",
|
||||||
|
"expected": {
|
||||||
|
"source_pack_id": "e10-lidar-pack-576c994a6c814e2592dd6240ace3902a5db94843312c759a73ba0c9166157d2b",
|
||||||
|
"local_surface_model_id": "k1-local-surface-23762244c8bdb97de26fb721ac957d7a00bc9a63571ac4cfa4be19c4effc7d55",
|
||||||
|
"detector_result_id": "result-f4cebdea8a82698a5b8a65d2c3fbdb0428b88b9dc49fe45f8cb37d740ed83d02",
|
||||||
|
"baseline_e29_result_id": "e29-camera-geometry-421a9d930638bef12cd5eb10979a477917fa4a389e655ed95f73ba4bd62e13dc"
|
||||||
|
},
|
||||||
|
"camera_candidate": {
|
||||||
|
"target_labels": [
|
||||||
|
"person",
|
||||||
|
"bicycle",
|
||||||
|
"car",
|
||||||
|
"motorcycle",
|
||||||
|
"bus",
|
||||||
|
"truck"
|
||||||
|
],
|
||||||
|
"label_groups": {
|
||||||
|
"person": [
|
||||||
|
"person"
|
||||||
|
],
|
||||||
|
"two-wheel": [
|
||||||
|
"bicycle",
|
||||||
|
"motorcycle"
|
||||||
|
],
|
||||||
|
"vehicle": [
|
||||||
|
"car",
|
||||||
|
"bus",
|
||||||
|
"truck"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"minimum_score": 0.5,
|
||||||
|
"maximum_bbox_image_fraction": 0.25
|
||||||
|
},
|
||||||
|
"temporal": {
|
||||||
|
"minimum_adjacent_family_iou": 0.25,
|
||||||
|
"minimum_baseline_family_iou": 0.25,
|
||||||
|
"interpretation": "adjacent-image-space-continuity-diagnostic-not-track-truth"
|
||||||
|
},
|
||||||
|
"acceptance": {
|
||||||
|
"maximum_frame_latency_p95_ms": 50.0,
|
||||||
|
"maximum_rss_growth_mib": 512.0,
|
||||||
|
"require_complete_frame_accounting": true,
|
||||||
|
"require_canonical_e29_association": true,
|
||||||
|
"require_camera_semantic_ownership": true,
|
||||||
|
"require_no_lidar_native_semantics": true,
|
||||||
|
"require_no_false_free_space": true,
|
||||||
|
"require_no_collision_authority": true,
|
||||||
|
"require_upstream_immutability": true
|
||||||
|
},
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"navigation_or_safety_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"schema_version": "missioncore.rig-geometry/v1",
|
||||||
|
"profile_id": "ravnoves00-portable-k1/unbound-v1",
|
||||||
|
"rig_kind": "portable",
|
||||||
|
"qualification": {
|
||||||
|
"state": "unbound",
|
||||||
|
"reason": "portable-k1-source-has-no-vehicle-body-or-measured-lidar-to-body-mount",
|
||||||
|
"evidence_sha256": []
|
||||||
|
},
|
||||||
|
"coordinate_frames": {
|
||||||
|
"lidar_frame": "k1-lidar",
|
||||||
|
"body_frame": null,
|
||||||
|
"body_from_lidar": null
|
||||||
|
},
|
||||||
|
"vehicle_body": null,
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": false,
|
||||||
|
"navigation_or_safety_accepted": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the immutable full-source E53 camera-first shadow."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from k1link.compute.e53_camera_first_shadow import (
|
||||||
|
build_e53_camera_first_shadow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--source-pack", type=Path, required=True)
|
||||||
|
parser.add_argument("--local-surface", type=Path, required=True)
|
||||||
|
parser.add_argument("--detector-result", type=Path, required=True)
|
||||||
|
parser.add_argument("--baseline-e29", type=Path, required=True)
|
||||||
|
parser.add_argument("--rig-geometry", 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_e53_camera_first_shadow(
|
||||||
|
source_pack_root=args.source_pack,
|
||||||
|
local_surface_root=args.local_surface,
|
||||||
|
detector_result_root=args.detector_result,
|
||||||
|
baseline_e29_root=args.baseline_e29,
|
||||||
|
rig_geometry_path=args.rig_geometry,
|
||||||
|
profile_path=args.profile,
|
||||||
|
output_root=args.output_root,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"result_id": result.result_id,
|
||||||
|
"result_root": str(result.result_root),
|
||||||
|
"accepted": result.report["acceptance"]["accepted"],
|
||||||
|
"metrics": result.report["metrics"],
|
||||||
|
"decision": result.report["decision"],
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
|||||||
|
"""Versioned, fail-closed vehicle-body and sensor-mount geometry."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Final, Literal
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
RIG_GEOMETRY_SCHEMA: Final = "missioncore.rig-geometry/v1"
|
||||||
|
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||||
|
_SAFE_ID = re.compile(r"^[a-z0-9][a-z0-9._/-]{0,159}$")
|
||||||
|
_AUTHORITY: Final = {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RigGeometryError(ValueError):
|
||||||
|
"""A rig geometry document is incomplete, ambiguous, or inconsistent."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RigGeometry:
|
||||||
|
"""Validated rig geometry plus its content identity."""
|
||||||
|
|
||||||
|
document: Mapping[str, Any]
|
||||||
|
identity_sha256: str
|
||||||
|
qualification_state: Literal["unbound", "measured", "qualified"]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metric_body_geometry_available(self) -> bool:
|
||||||
|
return self.qualification_state in {"measured", "qualified"}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def collision_geometry_qualified(self) -> bool:
|
||||||
|
return self.qualification_state == "qualified"
|
||||||
|
|
||||||
|
def collision_contract(self) -> dict[str, object]:
|
||||||
|
if self.collision_geometry_qualified:
|
||||||
|
return {
|
||||||
|
"state": "geometry-qualified",
|
||||||
|
"geometry_profile_sha256": self.identity_sha256,
|
||||||
|
"recent_collision_publishable": False,
|
||||||
|
"reason": "collision-algorithm-and-independent-safety-gate-not-qualified",
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"state": "unavailable",
|
||||||
|
"geometry_profile_sha256": self.identity_sha256,
|
||||||
|
"recent_collision_publishable": False,
|
||||||
|
"reason": (
|
||||||
|
"vehicle-body-and-lidar-mount-geometry-not-bound"
|
||||||
|
if self.qualification_state == "unbound"
|
||||||
|
else "vehicle-body-and-lidar-mount-geometry-not-qualified"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_rig_geometry(path: Path) -> RigGeometry:
|
||||||
|
"""Read and validate one regular JSON geometry profile."""
|
||||||
|
|
||||||
|
source = path.expanduser().absolute()
|
||||||
|
if source.is_symlink():
|
||||||
|
raise RigGeometryError("rig geometry profile cannot be a symlink")
|
||||||
|
resolved = source.resolve(strict=True)
|
||||||
|
if not resolved.is_file():
|
||||||
|
raise RigGeometryError("rig geometry profile must be a regular file")
|
||||||
|
try:
|
||||||
|
value = json.loads(resolved.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
raise RigGeometryError("rig geometry profile is not valid JSON") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RigGeometryError("rig geometry profile must be an object")
|
||||||
|
return parse_rig_geometry(value)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_rig_geometry(value: Mapping[str, Any]) -> RigGeometry:
|
||||||
|
"""Validate an in-memory v1 profile without inferring physical values."""
|
||||||
|
|
||||||
|
document = dict(value)
|
||||||
|
profile_id = document.get("profile_id")
|
||||||
|
rig_kind = document.get("rig_kind")
|
||||||
|
qualification = _mapping(document.get("qualification"), "qualification")
|
||||||
|
frames = _mapping(document.get("coordinate_frames"), "coordinate_frames")
|
||||||
|
state = qualification.get("state")
|
||||||
|
reason = qualification.get("reason")
|
||||||
|
evidence_ids = qualification.get("evidence_sha256")
|
||||||
|
if (
|
||||||
|
document.get("schema_version") != RIG_GEOMETRY_SCHEMA
|
||||||
|
or not isinstance(profile_id, str)
|
||||||
|
or _SAFE_ID.fullmatch(profile_id) is None
|
||||||
|
or rig_kind not in {"portable", "vehicle-mounted"}
|
||||||
|
or state not in {"unbound", "measured", "qualified"}
|
||||||
|
or not isinstance(reason, str)
|
||||||
|
or not reason.strip()
|
||||||
|
or len(reason) > 240
|
||||||
|
or not isinstance(evidence_ids, list)
|
||||||
|
or any(
|
||||||
|
not isinstance(item, str) or _SHA256.fullmatch(item) is None
|
||||||
|
for item in evidence_ids
|
||||||
|
)
|
||||||
|
or len(set(evidence_ids)) != len(evidence_ids)
|
||||||
|
or document.get("authority") != _AUTHORITY
|
||||||
|
):
|
||||||
|
raise RigGeometryError("rig geometry profile identity is invalid")
|
||||||
|
|
||||||
|
lidar_frame = frames.get("lidar_frame")
|
||||||
|
body_frame = frames.get("body_frame")
|
||||||
|
body_from_lidar = frames.get("body_from_lidar")
|
||||||
|
body = document.get("vehicle_body")
|
||||||
|
if not isinstance(lidar_frame, str) or _SAFE_ID.fullmatch(lidar_frame) is None:
|
||||||
|
raise RigGeometryError("rig geometry LiDAR frame is invalid")
|
||||||
|
|
||||||
|
if state == "unbound":
|
||||||
|
if (
|
||||||
|
rig_kind != "portable"
|
||||||
|
or body_frame is not None
|
||||||
|
or body_from_lidar is not None
|
||||||
|
or body is not None
|
||||||
|
or evidence_ids
|
||||||
|
):
|
||||||
|
raise RigGeometryError("unbound rig geometry must not contain physical values")
|
||||||
|
else:
|
||||||
|
if (
|
||||||
|
rig_kind != "vehicle-mounted"
|
||||||
|
or not isinstance(body_frame, str)
|
||||||
|
or _SAFE_ID.fullmatch(body_frame) is None
|
||||||
|
):
|
||||||
|
raise RigGeometryError("mounted rig body frame is invalid")
|
||||||
|
_validate_transform(
|
||||||
|
_mapping(body_from_lidar, "body_from_lidar"),
|
||||||
|
body_frame=body_frame,
|
||||||
|
lidar_frame=lidar_frame,
|
||||||
|
)
|
||||||
|
_validate_body(_mapping(body, "vehicle_body"), body_frame=body_frame)
|
||||||
|
if not evidence_ids:
|
||||||
|
raise RigGeometryError("measured rig geometry requires measurement evidence")
|
||||||
|
uncertainty = _mapping(
|
||||||
|
qualification.get("uncertainty"),
|
||||||
|
"qualification uncertainty",
|
||||||
|
)
|
||||||
|
_positive_number(
|
||||||
|
uncertainty.get("translation_1sigma_m"),
|
||||||
|
"translation uncertainty",
|
||||||
|
)
|
||||||
|
_positive_number(
|
||||||
|
uncertainty.get("rotation_1sigma_deg"),
|
||||||
|
"rotation uncertainty",
|
||||||
|
)
|
||||||
|
_positive_number(
|
||||||
|
uncertainty.get("body_dimension_1sigma_m"),
|
||||||
|
"body-dimension uncertainty",
|
||||||
|
)
|
||||||
|
if state == "qualified":
|
||||||
|
qualification_method = qualification.get("qualification_method")
|
||||||
|
if (
|
||||||
|
not isinstance(qualification_method, str)
|
||||||
|
or not qualification_method.strip()
|
||||||
|
or len(qualification_method) > 240
|
||||||
|
):
|
||||||
|
raise RigGeometryError("qualified rig geometry needs a method")
|
||||||
|
|
||||||
|
canonical = _canonical_json(document)
|
||||||
|
return RigGeometry(
|
||||||
|
document=document,
|
||||||
|
identity_sha256=hashlib.sha256(canonical).hexdigest(),
|
||||||
|
qualification_state=state,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_transform(
|
||||||
|
value: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
body_frame: str,
|
||||||
|
lidar_frame: str,
|
||||||
|
) -> None:
|
||||||
|
translation = _vector(value.get("translation_m"), 3, "mount translation")
|
||||||
|
quaternion = _vector(value.get("quaternion_xyzw"), 4, "mount quaternion")
|
||||||
|
if (
|
||||||
|
value.get("from_frame") != lidar_frame
|
||||||
|
or value.get("to_frame") != body_frame
|
||||||
|
or not math.isclose(float(np.linalg.norm(quaternion)), 1.0, abs_tol=1e-6)
|
||||||
|
or np.linalg.norm(translation) > 20.0
|
||||||
|
):
|
||||||
|
raise RigGeometryError("rig mount transform is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_body(value: Mapping[str, Any], *, body_frame: str) -> None:
|
||||||
|
minimum = _vector(value.get("minimum_xyz_m"), 3, "body minimum")
|
||||||
|
maximum = _vector(value.get("maximum_xyz_m"), 3, "body maximum")
|
||||||
|
if (
|
||||||
|
value.get("frame") != body_frame
|
||||||
|
or value.get("shape") != "axis-aligned-box"
|
||||||
|
or np.any(maximum <= minimum)
|
||||||
|
or np.any(maximum - minimum > 30.0)
|
||||||
|
):
|
||||||
|
raise RigGeometryError("vehicle body envelope is invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping(value: object, label: str) -> Mapping[str, Any]:
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
raise RigGeometryError(f"{label} must be an object")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _vector(value: object, length: int, label: str) -> np.ndarray:
|
||||||
|
if not isinstance(value, list) or len(value) != length:
|
||||||
|
raise RigGeometryError(f"{label} is invalid")
|
||||||
|
array = np.asarray(value, dtype=np.float64)
|
||||||
|
if not np.isfinite(array).all():
|
||||||
|
raise RigGeometryError(f"{label} is invalid")
|
||||||
|
return array
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_number(value: object, label: str) -> float:
|
||||||
|
if not isinstance(value, (int, float)) or isinstance(value, bool):
|
||||||
|
raise RigGeometryError(f"{label} is invalid")
|
||||||
|
number = float(value)
|
||||||
|
if not math.isfinite(number) or number <= 0.0:
|
||||||
|
raise RigGeometryError(f"{label} is invalid")
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def _canonical_json(value: object) -> bytes:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
allow_nan=False,
|
||||||
|
).encode()
|
||||||
@@ -138,6 +138,65 @@ class _GeometryClusterSupport:
|
|||||||
occupied_source_indices: IntArray
|
occupied_source_indices: IntArray
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CameraGeometryFrameEvaluation:
|
||||||
|
"""One camera-owned semantic frame with LiDAR-owned metric association."""
|
||||||
|
|
||||||
|
semantic_observations: tuple[dict[str, object], ...]
|
||||||
|
geometry_only_occupied: tuple[dict[str, object], ...]
|
||||||
|
claimed_source_indices: frozenset[int]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_camera_geometry_frame(
|
||||||
|
*,
|
||||||
|
objects: Iterable[Mapping[str, Any]],
|
||||||
|
projected: ProjectedPointCloud | None,
|
||||||
|
frame_points_map: FloatArray,
|
||||||
|
point_class: npt.NDArray[np.uint8],
|
||||||
|
point_height_m: npt.NDArray[np.float32],
|
||||||
|
sensor_position_map: FloatArray,
|
||||||
|
source_available: bool,
|
||||||
|
surface_valid: bool,
|
||||||
|
profile: CameraGeometryFusionProfile = DEFAULT_CAMERA_GEOMETRY_FUSION_PROFILE,
|
||||||
|
include_geometry_only: bool = True,
|
||||||
|
) -> CameraGeometryFrameEvaluation:
|
||||||
|
"""Apply the canonical E29 association without changing semantic ownership."""
|
||||||
|
|
||||||
|
supports = tuple(
|
||||||
|
_semantic_support(
|
||||||
|
item,
|
||||||
|
projected=projected,
|
||||||
|
frame_points_map=frame_points_map,
|
||||||
|
point_class=point_class,
|
||||||
|
point_height_m=point_height_m,
|
||||||
|
source_available=source_available,
|
||||||
|
surface_valid=surface_valid,
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
for item in objects
|
||||||
|
)
|
||||||
|
claimed = frozenset(_claimed_indices(supports))
|
||||||
|
geometry = (
|
||||||
|
tuple(
|
||||||
|
_geometry_clusters(
|
||||||
|
points_map=frame_points_map,
|
||||||
|
point_class=point_class,
|
||||||
|
point_height_m=point_height_m,
|
||||||
|
sensor_position_map=sensor_position_map,
|
||||||
|
claimed_source_indices=set(claimed),
|
||||||
|
profile=profile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if include_geometry_only
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
return CameraGeometryFrameEvaluation(
|
||||||
|
semantic_observations=tuple(support.document for support in supports),
|
||||||
|
geometry_only_occupied=geometry,
|
||||||
|
claimed_source_indices=claimed,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_camera_geometry_fusion(
|
def build_camera_geometry_fusion(
|
||||||
*,
|
*,
|
||||||
fusion_frames_path: Path,
|
fusion_frames_path: Path,
|
||||||
@@ -229,7 +288,6 @@ def build_camera_geometry_fusion(
|
|||||||
frame_heights = point_height[start:end]
|
frame_heights = point_height[start:end]
|
||||||
source_available = bool(arrays["sample_available"][expected_frame_index])
|
source_available = bool(arrays["sample_available"][expected_frame_index])
|
||||||
surface_valid = bool(surface.arrays["frame_valid"][expected_frame_index])
|
surface_valid = bool(surface.arrays["frame_valid"][expected_frame_index])
|
||||||
semantic_supports: list[_SemanticSupport] = []
|
|
||||||
if source_available and surface_valid:
|
if source_available and surface_valid:
|
||||||
position = arrays["pose_positions_map"][expected_frame_index]
|
position = arrays["pose_positions_map"][expected_frame_index]
|
||||||
orientation = arrays["pose_quaternions_map_from_lidar"][expected_frame_index]
|
orientation = arrays["pose_quaternions_map_from_lidar"][expected_frame_index]
|
||||||
@@ -251,40 +309,32 @@ def build_camera_geometry_fusion(
|
|||||||
else:
|
else:
|
||||||
projected = None
|
projected = None
|
||||||
|
|
||||||
for item in frame["objects"]:
|
evaluation = evaluate_camera_geometry_frame(
|
||||||
support = _semantic_support(
|
objects=frame["objects"],
|
||||||
item,
|
projected=projected,
|
||||||
projected=projected,
|
frame_points_map=frame_points,
|
||||||
frame_points_map=frame_points,
|
|
||||||
point_class=frame_classes,
|
|
||||||
point_height_m=frame_heights,
|
|
||||||
source_available=source_available,
|
|
||||||
surface_valid=surface_valid,
|
|
||||||
profile=profile,
|
|
||||||
)
|
|
||||||
semantic_supports.append(support)
|
|
||||||
semantic_observations += 1
|
|
||||||
geometry_status = str(support.document["geometry_status"])
|
|
||||||
group = str(support.document["association_group"])
|
|
||||||
motion_status = str(support.document["motion_status"])
|
|
||||||
status_counts[geometry_status] += 1
|
|
||||||
group_status_counts[(group, geometry_status)] += 1
|
|
||||||
motion_status_counts[(motion_status, geometry_status)] += 1
|
|
||||||
if support.document["semantic_current"] is True:
|
|
||||||
semantic_current_observations += 1
|
|
||||||
|
|
||||||
claimed = _claimed_indices(semantic_supports)
|
|
||||||
geometry_clusters = _geometry_clusters(
|
|
||||||
points_map=frame_points,
|
|
||||||
point_class=frame_classes,
|
point_class=frame_classes,
|
||||||
point_height_m=frame_heights,
|
point_height_m=frame_heights,
|
||||||
sensor_position_map=np.asarray(
|
sensor_position_map=np.asarray(
|
||||||
arrays["pose_positions_map"][expected_frame_index],
|
arrays["pose_positions_map"][expected_frame_index],
|
||||||
dtype=np.float64,
|
dtype=np.float64,
|
||||||
),
|
),
|
||||||
claimed_source_indices=claimed,
|
source_available=source_available,
|
||||||
|
surface_valid=surface_valid,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
)
|
)
|
||||||
|
for support_document in evaluation.semantic_observations:
|
||||||
|
semantic_observations += 1
|
||||||
|
geometry_status = str(support_document["geometry_status"])
|
||||||
|
group = str(support_document["association_group"])
|
||||||
|
motion_status = str(support_document["motion_status"])
|
||||||
|
status_counts[geometry_status] += 1
|
||||||
|
group_status_counts[(group, geometry_status)] += 1
|
||||||
|
motion_status_counts[(motion_status, geometry_status)] += 1
|
||||||
|
if support_document["semantic_current"] is True:
|
||||||
|
semantic_current_observations += 1
|
||||||
|
|
||||||
|
geometry_clusters = list(evaluation.geometry_only_occupied)
|
||||||
if geometry_clusters:
|
if geometry_clusters:
|
||||||
frames_with_geometry_only += 1
|
frames_with_geometry_only += 1
|
||||||
geometry_only_clusters_per_frame.append(float(len(geometry_clusters)))
|
geometry_only_clusters_per_frame.append(float(len(geometry_clusters)))
|
||||||
@@ -308,7 +358,7 @@ def build_camera_geometry_fusion(
|
|||||||
"session_seconds": frame["session_seconds"],
|
"session_seconds": frame["session_seconds"],
|
||||||
"source_available": source_available,
|
"source_available": source_available,
|
||||||
"local_surface_valid": surface_valid,
|
"local_surface_valid": surface_valid,
|
||||||
"semantic_observations": [support.document for support in semantic_supports],
|
"semantic_observations": list(evaluation.semantic_observations),
|
||||||
"geometry_only_occupied": geometry_clusters,
|
"geometry_only_occupied": geometry_clusters,
|
||||||
"policy": {
|
"policy": {
|
||||||
"camera_owns_semantics": True,
|
"camera_owns_semantics": True,
|
||||||
@@ -741,7 +791,9 @@ def _spatial_cluster(
|
|||||||
return rows[np.asarray(selected, dtype=np.int64)]
|
return rows[np.asarray(selected, dtype=np.int64)]
|
||||||
|
|
||||||
|
|
||||||
def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile:
|
def projection_profile_from_source(source: E10LidarFieldSource) -> Kb4ProjectionProfile:
|
||||||
|
"""Build the source-bound factory projection used by E29 association."""
|
||||||
|
|
||||||
identity_projection = source.identity.get("projection")
|
identity_projection = source.identity.get("projection")
|
||||||
if not isinstance(identity_projection, Mapping):
|
if not isinstance(identity_projection, Mapping):
|
||||||
raise SemanticGeometryFusionError("source projection identity is missing")
|
raise SemanticGeometryFusionError("source projection identity is missing")
|
||||||
@@ -771,6 +823,10 @@ def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _projection_profile(source: E10LidarFieldSource) -> Kb4ProjectionProfile:
|
||||||
|
return projection_profile_from_source(source)
|
||||||
|
|
||||||
|
|
||||||
def _fusion_frame(
|
def _fusion_frame(
|
||||||
line: str,
|
line: str,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from k1link.compute.e53_camera_first_shadow import (
|
||||||
|
_Profile,
|
||||||
|
adjacent_family_matches,
|
||||||
|
select_candidate_detections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _profile() -> _Profile:
|
||||||
|
return _Profile(
|
||||||
|
raw={},
|
||||||
|
expected_source_pack_id="source",
|
||||||
|
expected_local_surface_id="surface",
|
||||||
|
expected_detector_result_id="detector",
|
||||||
|
expected_baseline_e29_id="baseline",
|
||||||
|
target_labels=frozenset({"person", "car", "truck"}),
|
||||||
|
label_groups={
|
||||||
|
"person": ("person",),
|
||||||
|
"vehicle": ("car", "truck"),
|
||||||
|
},
|
||||||
|
minimum_detector_score=0.5,
|
||||||
|
maximum_bbox_image_fraction=0.25,
|
||||||
|
adjacent_family_iou=0.25,
|
||||||
|
baseline_family_iou=0.25,
|
||||||
|
maximum_latency_p95_ms=50.0,
|
||||||
|
maximum_rss_growth_mib=512.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_candidate_filter_keeps_camera_semantics_and_rejects_large_box() -> None:
|
||||||
|
selected, counts = select_candidate_detections(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"instance_id": 1,
|
||||||
|
"label": "person",
|
||||||
|
"score": 0.99,
|
||||||
|
"box_xyxy": [100.0, 100.0, 180.0, 300.0],
|
||||||
|
"mask_pixels": 4000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instance_id": 2,
|
||||||
|
"label": "car",
|
||||||
|
"score": 0.95,
|
||||||
|
"box_xyxy": [0.0, 0.0, 800.0, 600.0],
|
||||||
|
"mask_pixels": 480000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"instance_id": 3,
|
||||||
|
"label": "laptop",
|
||||||
|
"score": 0.98,
|
||||||
|
"box_xyxy": [10.0, 10.0, 100.0, 100.0],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
width=800,
|
||||||
|
height=600,
|
||||||
|
profile=_profile(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(selected) == 1
|
||||||
|
assert selected[0]["label"] == "person"
|
||||||
|
assert selected[0]["association_group"] == "person"
|
||||||
|
assert selected[0]["cuboid_status"] == "camera-candidate-no-cuboid"
|
||||||
|
assert counts["pathological_large_box"] == 1
|
||||||
|
assert counts["non_target"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_adjacent_matching_is_family_aware_and_one_to_one() -> None:
|
||||||
|
previous: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"association_group": "vehicle",
|
||||||
|
"bbox_xyxy": [10.0, 10.0, 110.0, 110.0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"association_group": "person",
|
||||||
|
"bbox_xyxy": [200.0, 20.0, 240.0, 120.0],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
current: list[dict[str, Any]] = [
|
||||||
|
{
|
||||||
|
"association_group": "vehicle",
|
||||||
|
"bbox_xyxy": [12.0, 12.0, 112.0, 112.0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"association_group": "vehicle",
|
||||||
|
"bbox_xyxy": [14.0, 14.0, 114.0, 114.0],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"association_group": "person",
|
||||||
|
"bbox_xyxy": [202.0, 22.0, 242.0, 122.0],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert adjacent_family_matches(previous, current, minimum_iou=0.25) == 2
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.compute.rig_geometry import (
|
||||||
|
RIG_GEOMETRY_SCHEMA,
|
||||||
|
RigGeometryError,
|
||||||
|
parse_rig_geometry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unbound() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"schema_version": RIG_GEOMETRY_SCHEMA,
|
||||||
|
"profile_id": "portable-k1/unbound-v1",
|
||||||
|
"rig_kind": "portable",
|
||||||
|
"qualification": {
|
||||||
|
"state": "unbound",
|
||||||
|
"reason": "no measured mount",
|
||||||
|
"evidence_sha256": [],
|
||||||
|
},
|
||||||
|
"coordinate_frames": {
|
||||||
|
"lidar_frame": "k1-lidar",
|
||||||
|
"body_frame": None,
|
||||||
|
"body_from_lidar": None,
|
||||||
|
},
|
||||||
|
"vehicle_body": None,
|
||||||
|
"authority": {
|
||||||
|
"commands_enabled": False,
|
||||||
|
"navigation_or_safety_accepted": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_portable_rig_fails_closed_without_inventing_geometry() -> None:
|
||||||
|
geometry = parse_rig_geometry(_unbound())
|
||||||
|
|
||||||
|
assert geometry.metric_body_geometry_available is False
|
||||||
|
assert geometry.collision_geometry_qualified is False
|
||||||
|
assert geometry.collision_contract()["state"] == "unavailable"
|
||||||
|
assert geometry.collision_contract()["recent_collision_publishable"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbound_rig_rejects_hidden_physical_values() -> None:
|
||||||
|
value = _unbound()
|
||||||
|
value["coordinate_frames"] = {
|
||||||
|
"lidar_frame": "k1-lidar",
|
||||||
|
"body_frame": "vehicle-body",
|
||||||
|
"body_from_lidar": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(RigGeometryError, match="must not contain physical values"):
|
||||||
|
parse_rig_geometry(value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_measured_mount_does_not_grant_collision_authority() -> None:
|
||||||
|
value = _unbound()
|
||||||
|
value.update(
|
||||||
|
{
|
||||||
|
"profile_id": "measured-rig/v1",
|
||||||
|
"rig_kind": "vehicle-mounted",
|
||||||
|
"qualification": {
|
||||||
|
"state": "measured",
|
||||||
|
"reason": "bench measurement only",
|
||||||
|
"evidence_sha256": ["a" * 64],
|
||||||
|
"uncertainty": {
|
||||||
|
"translation_1sigma_m": 0.01,
|
||||||
|
"rotation_1sigma_deg": 0.2,
|
||||||
|
"body_dimension_1sigma_m": 0.01,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"coordinate_frames": {
|
||||||
|
"lidar_frame": "k1-lidar",
|
||||||
|
"body_frame": "vehicle-body",
|
||||||
|
"body_from_lidar": {
|
||||||
|
"from_frame": "k1-lidar",
|
||||||
|
"to_frame": "vehicle-body",
|
||||||
|
"translation_m": [0.0, 0.0, 1.0],
|
||||||
|
"quaternion_xyzw": [0.0, 0.0, 0.0, 1.0],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"vehicle_body": {
|
||||||
|
"frame": "vehicle-body",
|
||||||
|
"shape": "axis-aligned-box",
|
||||||
|
"minimum_xyz_m": [-1.0, -0.5, -0.2],
|
||||||
|
"maximum_xyz_m": [1.0, 0.5, 1.2],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
geometry = parse_rig_geometry(value)
|
||||||
|
|
||||||
|
assert geometry.metric_body_geometry_available is True
|
||||||
|
assert geometry.collision_geometry_qualified is False
|
||||||
|
assert geometry.collision_contract()["state"] == "unavailable"
|
||||||
Reference in New Issue
Block a user