feat(observatory): ship modular AI inference labs
This commit is contained in:
+108
-64
@@ -42,11 +42,19 @@ from k1link.observatory import (
|
||||
ObservatoryRunPreparationLedger,
|
||||
load_observatory_run_preparation_ledger,
|
||||
)
|
||||
from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
|
||||
from k1link.observatory.domain_ontology import (
|
||||
ObservatoryDomainOntology,
|
||||
ObservatoryOntologyError,
|
||||
)
|
||||
from k1link.observatory.lab_view_profiles import LabViewProfileError, LabViewProfileStore
|
||||
from k1link.observatory.m49_queue_binding import (
|
||||
M49QueueBindingConfig,
|
||||
M49QueueBindingError,
|
||||
M49RecordedQueueBindingService,
|
||||
)
|
||||
from k1link.observatory.modular_composition import CompositionError, ModuleRegistry
|
||||
from k1link.observatory.modular_composition_store import ModularCompositionStore
|
||||
from k1link.observatory.portable_publication_reconciler import (
|
||||
PortablePublicationReconciler,
|
||||
)
|
||||
@@ -196,6 +204,7 @@ from k1link.web.map_api import (
|
||||
build_map_router,
|
||||
)
|
||||
from k1link.web.map_view_api import build_map_view_router
|
||||
from k1link.web.modular_observatory_api import build_modular_observatory_router
|
||||
from k1link.web.observatory_api import build_observatory_router
|
||||
from k1link.web.observatory_worker_api import (
|
||||
ObservatoryWorkerAuthentication,
|
||||
@@ -288,6 +297,37 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT)
|
||||
plugin_catalog: DevicePluginCatalog = plugin_environment.catalog
|
||||
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
|
||||
session_store = SessionStore(REPOSITORY_ROOT)
|
||||
OBSERVATORY_AI_COMPOSITIONS: ModularCompositionStore | None
|
||||
OBSERVATORY_AI_COMPOSITION_RUNS: CompositionRunStore | None
|
||||
OBSERVATORY_DOMAIN_ONTOLOGY: ObservatoryDomainOntology | None
|
||||
OBSERVATORY_LAB_VIEW_PROFILES: LabViewProfileStore | None
|
||||
try:
|
||||
OBSERVATORY_DOMAIN_ONTOLOGY = ObservatoryDomainOntology.from_file(
|
||||
REPOSITORY_ROOT / "config" / "observatory-domain-ontology.json"
|
||||
)
|
||||
OBSERVATORY_AI_COMPOSITIONS = ModularCompositionStore(
|
||||
session_store.data_dir / "observatory-ai-compositions",
|
||||
ModuleRegistry.from_file(REPOSITORY_ROOT / "config" / "observatory-ai-modules.json"),
|
||||
)
|
||||
OBSERVATORY_AI_COMPOSITION_RUNS = CompositionRunStore(
|
||||
session_store.data_dir / "observatory-ai-composition-runs"
|
||||
)
|
||||
OBSERVATORY_LAB_VIEW_PROFILES = LabViewProfileStore(
|
||||
session_store.data_dir / "observatory-lab-view-profiles"
|
||||
)
|
||||
except (
|
||||
CompositionError,
|
||||
CompositionRunError,
|
||||
LabViewProfileError,
|
||||
ObservatoryOntologyError,
|
||||
OSError,
|
||||
ValueError,
|
||||
):
|
||||
# A modular catalog failure cannot disable recordings, existing LABs or Legacy.
|
||||
OBSERVATORY_AI_COMPOSITIONS = None
|
||||
OBSERVATORY_AI_COMPOSITION_RUNS = None
|
||||
OBSERVATORY_DOMAIN_ONTOLOGY = None
|
||||
OBSERVATORY_LAB_VIEW_PROFILES = None
|
||||
|
||||
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY: PortableRunDefinitionRegistry | None
|
||||
OBSERVATORY_PORTABLE_DEFINITION_REGISTRY_ERROR: str | None
|
||||
@@ -322,9 +362,7 @@ def _resolve_observatory_calculation_profile(
|
||||
summary: SessionSummary,
|
||||
) -> dict[str, object] | None:
|
||||
if OBSERVATORY_LABORATORY_SETUP_REGISTRY is not None:
|
||||
legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(
|
||||
summary
|
||||
)
|
||||
legacy = OBSERVATORY_LABORATORY_SETUP_REGISTRY.observatory_calculation_profile(summary)
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
if (
|
||||
@@ -437,47 +475,30 @@ try:
|
||||
or "portable definition registry is unavailable"
|
||||
)
|
||||
if OBSERVATORY_PORTABLE_CALCULATION_PROFILES is None:
|
||||
raise PortableWorkerIntegrationError(
|
||||
"portable calculation profile registry is unavailable"
|
||||
)
|
||||
raise PortableWorkerIntegrationError("portable calculation profile registry is unavailable")
|
||||
if OBSERVATORY_PORTABLE_RESULT_VALIDATORS is None:
|
||||
raise PortableWorkerIntegrationError(
|
||||
"portable result validator registry is unavailable"
|
||||
)
|
||||
raise PortableWorkerIntegrationError("portable result validator registry is unavailable")
|
||||
if OBSERVATORY_RECORDED_JOB_QUEUE is None:
|
||||
raise PortableWorkerIntegrationError(
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR
|
||||
or "Observatory recorded-job queue is unavailable"
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE_ERROR or "Observatory recorded-job queue is unavailable"
|
||||
)
|
||||
if session_artifact_gateway is None:
|
||||
raise PortableWorkerIntegrationError(
|
||||
"central artifact store is not configured"
|
||||
)
|
||||
raise PortableWorkerIntegrationError("central artifact store is not configured")
|
||||
if session_artifact_gateway.status().central_status != "ready":
|
||||
raise PortableWorkerIntegrationError(
|
||||
"central artifact store is unavailable"
|
||||
)
|
||||
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = (
|
||||
PortableWorkerStorageRoots.from_environment(
|
||||
artifact_store_root=session_artifact_gateway.store.root,
|
||||
)
|
||||
raise PortableWorkerIntegrationError("central artifact store is unavailable")
|
||||
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS = PortableWorkerStorageRoots.from_environment(
|
||||
artifact_store_root=session_artifact_gateway.store.root,
|
||||
)
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION = (
|
||||
build_portable_observatory_worker_integration(
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
session_store=session_store,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
|
||||
artifact_store=session_artifact_gateway.store,
|
||||
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
|
||||
validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
|
||||
source_cas_root=(
|
||||
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root
|
||||
),
|
||||
result_staging_root=(
|
||||
OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root
|
||||
),
|
||||
)
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION = build_portable_observatory_worker_integration(
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
session_store=session_store,
|
||||
media_inspector=session_recorded_media_inspector,
|
||||
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
|
||||
artifact_store=session_artifact_gateway.store,
|
||||
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
|
||||
validators=OBSERVATORY_PORTABLE_RESULT_VALIDATORS,
|
||||
source_cas_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.source_cas_root),
|
||||
result_staging_root=(OBSERVATORY_PORTABLE_WORKER_STORAGE_ROOTS.result_staging_root),
|
||||
)
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = None
|
||||
except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
|
||||
@@ -487,10 +508,7 @@ except (PortableWorkerIntegrationError, OSError, ValueError) as exc:
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR = str(exc)
|
||||
OBSERVATORY_PUBLICATION_RECONCILER = (
|
||||
None
|
||||
if (
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE is None
|
||||
or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None
|
||||
)
|
||||
if (OBSERVATORY_RECORDED_JOB_QUEUE is None or OBSERVATORY_PORTABLE_WORKER_INTEGRATION is None)
|
||||
else PortablePublicationReconciler(
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
artifact_transport=OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport,
|
||||
@@ -499,12 +517,10 @@ OBSERVATORY_PUBLICATION_RECONCILER = (
|
||||
)
|
||||
OBSERVATORY_WORKER_API_GATE_ENABLED = OBSERVATORY_WORKER_LOCAL_ENABLED
|
||||
OBSERVATORY_WORKER_CLAIM_LEASE_READY = (
|
||||
OBSERVATORY_WORKER_API_GATE_ENABLED
|
||||
and OBSERVATORY_RECORDED_JOB_QUEUE is not None
|
||||
OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_RECORDED_JOB_QUEUE is not None
|
||||
)
|
||||
OBSERVATORY_WORKER_VERIFIED_RESULT_PUBLISHER_READY = (
|
||||
OBSERVATORY_WORKER_API_GATE_ENABLED
|
||||
and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
|
||||
OBSERVATORY_WORKER_API_GATE_ENABLED and OBSERVATORY_PORTABLE_WORKER_INTEGRATION is not None
|
||||
)
|
||||
OBSERVATORY_WORKER_DISPATCH_READY = (
|
||||
OBSERVATORY_WORKER_CLAIM_LEASE_READY
|
||||
@@ -527,17 +543,13 @@ else:
|
||||
)
|
||||
if OBSERVATORY_WORKER_AUTHENTICATION_ERROR is not None:
|
||||
worker_api_errors.append(
|
||||
"authentication unavailable: "
|
||||
f"{OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
|
||||
f"authentication unavailable: {OBSERVATORY_WORKER_AUTHENTICATION_ERROR}"
|
||||
)
|
||||
if OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR is not None:
|
||||
worker_api_errors.append(
|
||||
"integration unavailable: "
|
||||
f"{OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
|
||||
f"integration unavailable: {OBSERVATORY_PORTABLE_WORKER_INTEGRATION_ERROR}"
|
||||
)
|
||||
OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(
|
||||
worker_api_errors
|
||||
)
|
||||
OBSERVATORY_WORKER_API_ERROR = "Worker pull API is disabled; " + "; ".join(worker_api_errors)
|
||||
OBSERVATORY_PORTABLE_BINDING_SERVICE: PortableRecordedQueueBindingService | None
|
||||
OBSERVATORY_PORTABLE_SETUP_PROJECTOR: PortableSetupProjector | None
|
||||
OBSERVATORY_PORTABLE_SETUP_PROJECTOR_ERROR: str | None
|
||||
@@ -554,7 +566,8 @@ try:
|
||||
and OBSERVATORY_PORTABLE_CALCULATION_PROFILES is not None
|
||||
):
|
||||
OBSERVATORY_PORTABLE_RESULT_CACHE = PortableResultCache(
|
||||
sessions=session_store, artifacts=session_artifact_gateway.store,
|
||||
sessions=session_store,
|
||||
artifacts=session_artifact_gateway.store,
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
|
||||
calculation_profiles=OBSERVATORY_PORTABLE_CALCULATION_PROFILES,
|
||||
@@ -566,7 +579,8 @@ try:
|
||||
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
published_result_available=(
|
||||
None if OBSERVATORY_PORTABLE_RESULT_CACHE is None
|
||||
None
|
||||
if OBSERVATORY_PORTABLE_RESULT_CACHE is None
|
||||
else OBSERVATORY_PORTABLE_RESULT_CACHE.available
|
||||
),
|
||||
)
|
||||
@@ -874,10 +888,19 @@ async def _portable_result_publication_reconciler() -> None:
|
||||
await asyncio.sleep(15.0)
|
||||
|
||||
|
||||
async def _recorded_blueprint_resource_reaper() -> None:
|
||||
from k1link.viewer.recorded import recorded_blueprint_sessions
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(30.0)
|
||||
await asyncio.to_thread(recorded_blueprint_sessions.expire)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
reconciler: asyncio.Task[None] | None = None
|
||||
publication_reconciler: asyncio.Task[None] | None = None
|
||||
blueprint_reaper: asyncio.Task[None] | None = None
|
||||
try:
|
||||
configure_scanner_diagnostics(session_store.data_dir / "logs")
|
||||
session_recording_preparation_manager.start()
|
||||
@@ -891,11 +914,17 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
|
||||
# expensive on field captures. Start it immediately in the background
|
||||
# instead of holding the ASGI startup gate.
|
||||
reconciler = asyncio.create_task(_recording_preparation_reconciler())
|
||||
publication_reconciler = asyncio.create_task(
|
||||
_portable_result_publication_reconciler()
|
||||
)
|
||||
publication_reconciler = asyncio.create_task(_portable_result_publication_reconciler())
|
||||
blueprint_reaper = asyncio.create_task(_recorded_blueprint_resource_reaper())
|
||||
yield
|
||||
finally:
|
||||
from k1link.viewer.recorded import recorded_blueprint_sessions
|
||||
|
||||
if blueprint_reaper is not None:
|
||||
blueprint_reaper.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await blueprint_reaper
|
||||
await asyncio.to_thread(recorded_blueprint_sessions.close)
|
||||
await map_gateway_proxy.close()
|
||||
if reconciler is not None:
|
||||
reconciler.cancel()
|
||||
@@ -1066,7 +1095,9 @@ if session_artifact_gateway is not None and _ffmpeg is not None:
|
||||
media=session_recorded_media_inspector,
|
||||
recording_source=_canonical_lab_recording_source,
|
||||
ffmpeg_path=_ffmpeg,
|
||||
)
|
||||
),
|
||||
composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1128,6 +1159,23 @@ app.include_router(
|
||||
),
|
||||
)
|
||||
)
|
||||
if (
|
||||
OBSERVATORY_AI_COMPOSITIONS is not None
|
||||
and OBSERVATORY_AI_COMPOSITION_RUNS is not None
|
||||
and OBSERVATORY_DOMAIN_ONTOLOGY is not None
|
||||
):
|
||||
app.include_router(
|
||||
build_modular_observatory_router(
|
||||
store=session_store,
|
||||
compositions=OBSERVATORY_AI_COMPOSITIONS,
|
||||
composition_runs=OBSERVATORY_AI_COMPOSITION_RUNS,
|
||||
ontology=OBSERVATORY_DOMAIN_ONTOLOGY,
|
||||
definitions=OBSERVATORY_PORTABLE_DEFINITION_REGISTRY,
|
||||
binding=OBSERVATORY_PORTABLE_BINDING_SERVICE,
|
||||
queue=OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
view_profiles=OBSERVATORY_LAB_VIEW_PROFILES,
|
||||
)
|
||||
)
|
||||
if OBSERVATORY_WORKER_DISPATCH_READY:
|
||||
assert OBSERVATORY_RECORDED_JOB_QUEUE is not None
|
||||
assert OBSERVATORY_WORKER_AUTHENTICATION is not None
|
||||
@@ -1136,12 +1184,8 @@ if OBSERVATORY_WORKER_DISPATCH_READY:
|
||||
build_observatory_worker_router(
|
||||
OBSERVATORY_RECORDED_JOB_QUEUE,
|
||||
authentication=OBSERVATORY_WORKER_AUTHENTICATION,
|
||||
artifact_transport=(
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport
|
||||
),
|
||||
result_publisher=(
|
||||
OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher
|
||||
),
|
||||
artifact_transport=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.artifact_transport),
|
||||
result_publisher=(OBSERVATORY_PORTABLE_WORKER_INTEGRATION.result_publisher),
|
||||
)
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from k1link.observatory.composition_runs import (
|
||||
CompositionRun,
|
||||
CompositionRunError,
|
||||
CompositionRunStore,
|
||||
)
|
||||
from k1link.observatory.domain_ontology import ObservatoryDomainOntology, ObservatoryOntologyError
|
||||
from k1link.observatory.lab_view_profiles import (
|
||||
PROFILE_SCHEMA as LAB_VIEW_PROFILE_SCHEMA,
|
||||
)
|
||||
from k1link.observatory.lab_view_profiles import (
|
||||
LabSceneProfile,
|
||||
LabViewProfile,
|
||||
LabViewProfileError,
|
||||
LabViewProfileStore,
|
||||
)
|
||||
from k1link.observatory.modular_composition import COMPOSITION_SCHEMA, CompositionError
|
||||
from k1link.observatory.modular_composition_store import ModularCompositionStore
|
||||
from k1link.observatory.portable_queue_binding import (
|
||||
PortableQueueBindingError,
|
||||
PortableRecordedQueueBindingService,
|
||||
)
|
||||
from k1link.observatory.portable_run_definitions import PortableRunDefinitionRegistry
|
||||
from k1link.observatory.recorded_jobs import (
|
||||
ObservatoryRecordedJobQueue,
|
||||
ObservatoryRecordedQueueDuplicateError,
|
||||
ObservatoryRecordedQueueError,
|
||||
)
|
||||
from k1link.observatory.source_admission import PortableSourceNotPreparedError
|
||||
from k1link.sessions import SessionNotFoundError, SessionStore
|
||||
|
||||
_EXECUTABLE_SINGLE_MODULE_SETUPS = {
|
||||
"ddrnet": "ai-segmentation-ddrnet-v1",
|
||||
"eomt": "ai-segmentation-eomt-v1",
|
||||
"tgs": "m49-tgs-portable-v2",
|
||||
"rf-detr": "ai-detection-rf-detr-v1",
|
||||
"object-distance": "ai-range-object-distance-v1",
|
||||
}
|
||||
|
||||
|
||||
def _composition_error_detail(error: CompositionError) -> str:
|
||||
detail = str(error)
|
||||
if detail.startswith("unsupported value for "):
|
||||
return (
|
||||
"Параметры выбранного AI-модуля устарели. "
|
||||
"Закройте окно, откройте его снова и повторите расчёт."
|
||||
)
|
||||
if detail == "module version is not installed":
|
||||
return (
|
||||
"Версия выбранного AI-модуля обновилась. "
|
||||
"Закройте окно, откройте его снова и повторите расчёт."
|
||||
)
|
||||
if detail.startswith("select only one provider for "):
|
||||
return "В одном слое можно выбрать только один AI-модуль."
|
||||
if (
|
||||
detail.startswith("select a module providing ")
|
||||
or detail.startswith("ambiguous provider for ")
|
||||
or detail == "unresolved module dependencies"
|
||||
or detail == "cyclic module dependencies"
|
||||
):
|
||||
return "Для выбранной конфигурации не хватает обязательного связанного модуля."
|
||||
if detail == "select at least one AI module":
|
||||
return "Выберите хотя бы один AI-модуль."
|
||||
return "Конфигурацию AI-слоя не удалось проверить. Обновите окно и повторите выбор."
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class AICompositionRequest(_Strict):
|
||||
schema_version: str
|
||||
source_session_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
selections: list[dict[str, Any]] = Field(min_length=1, max_length=6)
|
||||
idempotency_key: str = Field(
|
||||
min_length=1,
|
||||
max_length=160,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$",
|
||||
)
|
||||
|
||||
|
||||
class AICompositionRunRenameRequest(_Strict):
|
||||
schema_version: str
|
||||
display_name: str = Field(min_length=1, max_length=160)
|
||||
|
||||
|
||||
class LabSceneProfileRequest(_Strict):
|
||||
point_size: float = Field(ge=0.1, allow_inf_nan=False)
|
||||
accumulation_seconds: float = Field(ge=0, allow_inf_nan=False)
|
||||
color_mode: str = Field(pattern=r"^(intensity|height|distance|rgb|class)$")
|
||||
palette: str = Field(pattern=r"^(turbo|viridis|plasma|grayscale)$")
|
||||
show_grid: bool
|
||||
show_labels: bool
|
||||
show_camera_frustums: bool
|
||||
|
||||
|
||||
class LabViewProfileRequest(_Strict):
|
||||
schema_version: str
|
||||
result_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$")
|
||||
scene_settings: LabSceneProfileRequest
|
||||
|
||||
|
||||
def build_modular_observatory_router(
|
||||
*,
|
||||
store: SessionStore,
|
||||
compositions: ModularCompositionStore,
|
||||
composition_runs: CompositionRunStore | None = None,
|
||||
ontology: ObservatoryDomainOntology | None = None,
|
||||
definitions: PortableRunDefinitionRegistry | None = None,
|
||||
binding: PortableRecordedQueueBindingService | None = None,
|
||||
queue: ObservatoryRecordedJobQueue | None = None,
|
||||
view_profiles: LabViewProfileStore | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/api/v1/observatory/ai-module-catalog")
|
||||
def catalog() -> dict[str, object]:
|
||||
return {
|
||||
**compositions.registry.catalog(),
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
@router.get("/api/v1/observatory/lab-view-profiles/{result_id}")
|
||||
def get_lab_view_profile(result_id: str) -> dict[str, object]:
|
||||
if view_profiles is None:
|
||||
raise HTTPException(503, "Профили отображения LAB недоступны.")
|
||||
try:
|
||||
profile = view_profiles.get(result_id)
|
||||
except LabViewProfileError as exc:
|
||||
raise HTTPException(422, "Некорректный профиль отображения LAB.") from exc
|
||||
if profile is None:
|
||||
raise HTTPException(404, "Профиль отображения LAB ещё не сохранён.")
|
||||
return profile.as_dict()
|
||||
|
||||
@router.put("/api/v1/observatory/lab-view-profiles/{result_id}")
|
||||
def put_lab_view_profile(result_id: str, request: LabViewProfileRequest) -> dict[str, object]:
|
||||
if view_profiles is None:
|
||||
raise HTTPException(503, "Профили отображения LAB недоступны.")
|
||||
if request.schema_version != LAB_VIEW_PROFILE_SCHEMA or request.result_id != result_id:
|
||||
raise HTTPException(422, "Профиль отображения относится к другой LAB.")
|
||||
try:
|
||||
settings = request.scene_settings
|
||||
profile = LabViewProfile(
|
||||
result_id=result_id,
|
||||
scene_settings=LabSceneProfile(**settings.model_dump()),
|
||||
updated_at_utc=datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
)
|
||||
return view_profiles.save(profile).as_dict()
|
||||
except LabViewProfileError as exc:
|
||||
raise HTTPException(422, "Некорректные настройки отображения LAB.") from exc
|
||||
|
||||
@router.post("/api/v1/observatory/ai-compositions")
|
||||
def save(request: AICompositionRequest) -> dict[str, object]:
|
||||
if request.schema_version != COMPOSITION_SCHEMA:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Версия конфигурации AI-слоя не поддерживается.",
|
||||
)
|
||||
try:
|
||||
source = store.get_session(request.source_session_id).summary
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Запись Обсерватории не найдена.") from exc
|
||||
if source.lab is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="AI-слой настраивается для исходной записи.",
|
||||
)
|
||||
selection = {"schema_version": request.schema_version, "selections": request.selections}
|
||||
try:
|
||||
composition, created = compositions.save(selection)
|
||||
except CompositionError as exc:
|
||||
raise HTTPException(status_code=409, detail=_composition_error_detail(exc)) from exc
|
||||
selected_modules = tuple(
|
||||
node.module.module_id
|
||||
for node in composition.nodes
|
||||
if node.module.group != "preparation"
|
||||
)
|
||||
selected = set(selected_modules)
|
||||
for previous in (
|
||||
()
|
||||
if composition_runs is None
|
||||
else composition_runs.list(source_session_id=source.session_id)
|
||||
):
|
||||
if previous.composition_sha256 != composition.sha256:
|
||||
continue
|
||||
try:
|
||||
previous_jobs = (
|
||||
tuple(queue.get(job_id) for job_id in previous.job_ids) if queue else ()
|
||||
)
|
||||
except (ObservatoryRecordedQueueError, ValueError):
|
||||
previous_jobs = ()
|
||||
if previous_jobs and all(
|
||||
job.state != "failed" and job.publication_state != "failed" for job in previous_jobs
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Эта конфигурация уже рассчитана или поставлена в очередь. "
|
||||
"Выберите другую конфигурацию."
|
||||
),
|
||||
)
|
||||
setup_ids: list[str] = []
|
||||
for module_id in ("ddrnet", "eomt", "tgs"):
|
||||
if module_id in selected:
|
||||
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS[module_id])
|
||||
if "object-distance" in selected:
|
||||
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["object-distance"])
|
||||
elif "rf-detr" in selected:
|
||||
setup_ids.append(_EXECUTABLE_SINGLE_MODULE_SETUPS["rf-detr"])
|
||||
jobs = []
|
||||
reason = "Для этой композиции ещё не установлен исполняемый пакет Worker 006."
|
||||
if setup_ids and definitions is not None and binding is not None:
|
||||
try:
|
||||
checked = []
|
||||
existing_by_setup = {}
|
||||
for setup_id in setup_ids:
|
||||
definition = definitions.resolve_setup(setup_id)
|
||||
existing = None
|
||||
if queue is not None:
|
||||
candidates = queue.list_jobs(
|
||||
source_session_id=source.session_id,
|
||||
setup_id=setup_id,
|
||||
definition_sha256=definition.definition_sha256,
|
||||
limit=20,
|
||||
)
|
||||
existing = next(
|
||||
(job for job in candidates if job.state != "failed"),
|
||||
None,
|
||||
)
|
||||
if existing is not None:
|
||||
existing_by_setup[setup_id] = existing
|
||||
continue
|
||||
try:
|
||||
check = binding.check(
|
||||
source_session_id=source.session_id,
|
||||
setup_id=setup_id,
|
||||
definition_sha256=definition.definition_sha256,
|
||||
)
|
||||
except PortableSourceNotPreparedError:
|
||||
check = binding.prepare_check(
|
||||
source_session_id=source.session_id,
|
||||
setup_id=setup_id,
|
||||
definition_sha256=definition.definition_sha256,
|
||||
)
|
||||
checked.append((setup_id, definition, check))
|
||||
for setup_id, definition, check in checked:
|
||||
key = hashlib.sha256(
|
||||
f"{request.idempotency_key}\0{setup_id}".encode()
|
||||
).hexdigest()
|
||||
job, _created = binding.submit(
|
||||
source_session_id=source.session_id,
|
||||
setup_id=setup_id,
|
||||
definition_sha256=definition.definition_sha256,
|
||||
expected_check_sha256=check.check_sha256,
|
||||
idempotency_key=f"ai-layer:{key}",
|
||||
)
|
||||
existing_by_setup[setup_id] = job
|
||||
jobs = [existing_by_setup[setup_id] for setup_id in setup_ids]
|
||||
if composition_runs is None and not checked:
|
||||
raise ObservatoryRecordedQueueDuplicateError("existing-composition")
|
||||
reason = (
|
||||
"Недостающие модули поставлены в очередь Worker 006; "
|
||||
"готовые результаты использованы повторно."
|
||||
if len(checked) < len(setup_ids)
|
||||
else "Композиция поставлена в очередь Worker 006."
|
||||
)
|
||||
except ObservatoryRecordedQueueDuplicateError as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Эта конфигурация уже рассчитана или поставлена в очередь. "
|
||||
"Выберите другую конфигурацию."
|
||||
),
|
||||
) from exc
|
||||
except (PortableQueueBindingError, ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Композицию не удалось поставить в очередь Worker 006.",
|
||||
) from exc
|
||||
if not jobs and definitions is not None and binding is not None:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Для этой конфигурации пока нет исполняемых модулей Worker 006.",
|
||||
)
|
||||
run_projection: dict[str, object] | None = None
|
||||
try:
|
||||
if composition_runs is None or ontology is None or not jobs:
|
||||
raise StopIteration
|
||||
run = composition_runs.save(
|
||||
source_session_id=source.session_id,
|
||||
composition=composition,
|
||||
setup_ids=tuple(setup_ids),
|
||||
job_ids=tuple(job.job_id for job in jobs),
|
||||
idempotency_key=request.idempotency_key,
|
||||
created_at_utc=datetime.now(UTC)
|
||||
.isoformat(timespec="milliseconds")
|
||||
.replace("+00:00", "Z"),
|
||||
)
|
||||
presentation = ontology.project_composition(composition)
|
||||
run_projection = {**run.as_dict(), "presentation": presentation}
|
||||
except StopIteration:
|
||||
pass
|
||||
except (CompositionRunError, ObservatoryOntologyError) as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Связь композиции с результатами не удалось сохранить.",
|
||||
) from exc
|
||||
return {
|
||||
"schema_version": (
|
||||
"missioncore.observatory-ai-composition-receipt/v3"
|
||||
if run_projection is not None
|
||||
else "missioncore.observatory-ai-composition-receipt/v2"
|
||||
),
|
||||
"source_session_id": source.session_id,
|
||||
"composition": composition.as_dict(),
|
||||
"composition_sha256": composition.sha256,
|
||||
"created": created,
|
||||
**({"run": run_projection} if run_projection is not None else {}),
|
||||
"dispatch": {
|
||||
"ready": len(jobs) == len(setup_ids) and bool(jobs),
|
||||
"reason": reason,
|
||||
"setup_ids": setup_ids,
|
||||
"jobs": [job.as_dict() for job in jobs],
|
||||
},
|
||||
}
|
||||
|
||||
def project_run(run: CompositionRun) -> dict[str, object]:
|
||||
if ontology is None:
|
||||
raise CompositionRunError("composition ontology is unavailable")
|
||||
try:
|
||||
exact = composition_runs.get(run.run_id)
|
||||
jobs = [queue.get(job_id) for job_id in exact.job_ids] if queue else []
|
||||
except (CompositionRunError, ObservatoryRecordedQueueError, ValueError):
|
||||
raise
|
||||
# The immutable composition document is the authority for projection;
|
||||
# reconstruct the selected module projection from the run's sealed IDs.
|
||||
presentation = ontology.project_module_ids(exact.module_ids)
|
||||
published = bool(jobs) and all(
|
||||
job.state == "succeeded" and job.publication_state == "published" and job.result_id
|
||||
for job in jobs
|
||||
)
|
||||
failed = any(job.state == "failed" or job.publication_state == "failed" for job in jobs)
|
||||
return {
|
||||
**exact.as_dict(),
|
||||
"state": "ready" if published else "failed" if failed else "running",
|
||||
"configuration_label": (
|
||||
f"{store.get_session(exact.source_session_id).summary.display_name} · "
|
||||
f"{presentation['configuration_label']}"
|
||||
),
|
||||
"display_name": composition_runs.display_name(exact.run_id),
|
||||
"presentation": presentation,
|
||||
"jobs": [job.as_dict() for job in jobs],
|
||||
"result_ids": [job.result_id for job in jobs if job.result_id is not None],
|
||||
}
|
||||
|
||||
@router.get("/api/v1/observatory/ai-composition-runs")
|
||||
def composition_run_list(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
items = (
|
||||
[]
|
||||
if composition_runs is None
|
||||
else [
|
||||
project_run(run)
|
||||
for run in composition_runs.list(
|
||||
source_session_id=source_session_id,
|
||||
include_hidden=False,
|
||||
)
|
||||
]
|
||||
)
|
||||
except (
|
||||
CompositionRunError,
|
||||
ObservatoryRecordedQueueError,
|
||||
ObservatoryOntologyError,
|
||||
ValueError,
|
||||
) as exc:
|
||||
raise HTTPException(503, "Композиции AI-слоя недоступны.") from exc
|
||||
return {
|
||||
"schema_version": "missioncore.observatory-ai-composition-run-list/v1",
|
||||
"items": items,
|
||||
}
|
||||
|
||||
@router.patch("/api/v1/observatory/ai-composition-runs/{run_id}")
|
||||
def rename_composition_run_projection(
|
||||
run_id: str,
|
||||
request: AICompositionRunRenameRequest,
|
||||
) -> dict[str, object]:
|
||||
if request.schema_version != "missioncore.observatory-ai-composition-run-rename/v1":
|
||||
raise HTTPException(422, "Версия переименования результата не поддерживается.")
|
||||
if composition_runs is None:
|
||||
raise HTTPException(503, "Композиции AI-слоя недоступны.")
|
||||
try:
|
||||
display_name = composition_runs.rename_projection(run_id, request.display_name)
|
||||
except CompositionRunError as exc:
|
||||
raise HTTPException(404, "Результат AI inference не найден.") from exc
|
||||
return {
|
||||
"schema_version": "missioncore.observatory-ai-composition-run-projection/v1",
|
||||
"run_id": run_id,
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
@router.delete(
|
||||
"/api/v1/observatory/ai-composition-runs/{run_id}",
|
||||
status_code=204,
|
||||
)
|
||||
def delete_composition_run_projection(run_id: str) -> Response:
|
||||
if composition_runs is None:
|
||||
raise HTTPException(503, "Композиции AI-слоя недоступны.")
|
||||
try:
|
||||
composition_runs.delete_projection(run_id)
|
||||
except CompositionRunError as exc:
|
||||
raise HTTPException(404, "Результат AI inference не найден.") from exc
|
||||
return Response(status_code=204)
|
||||
|
||||
@router.get("/api/v1/observatory/ai-runs")
|
||||
def runs(
|
||||
source_session_id: str = Query(
|
||||
min_length=1,
|
||||
max_length=128,
|
||||
pattern=r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$",
|
||||
),
|
||||
) -> dict[str, object]:
|
||||
if queue is None:
|
||||
raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.")
|
||||
try:
|
||||
jobs = [
|
||||
job
|
||||
for setup_id in _EXECUTABLE_SINGLE_MODULE_SETUPS.values()
|
||||
for job in queue.list_jobs(
|
||||
source_session_id=source_session_id,
|
||||
setup_id=setup_id,
|
||||
limit=20,
|
||||
)
|
||||
]
|
||||
except (ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(status_code=503, detail="Очередь AI-слоёв недоступна.") from exc
|
||||
jobs.sort(key=lambda job: job.created_at_utc, reverse=True)
|
||||
return {
|
||||
"schema_version": "missioncore.observatory-recorded-job-list/v1",
|
||||
"items": [job.as_dict() for job in jobs[:20]],
|
||||
"authority": {
|
||||
"commands_enabled": False,
|
||||
"actuation_allowed": False,
|
||||
"navigation_or_safety_accepted": False,
|
||||
"production_accepted": False,
|
||||
},
|
||||
}
|
||||
|
||||
return router
|
||||
@@ -9,15 +9,33 @@ from fastapi import APIRouter, HTTPException, Path, Query, Response
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from k1link.laboratory.canonical_rerun_overlay import CanonicalLabOverlayError
|
||||
from k1link.observatory.composition_runs import CompositionRunError, CompositionRunStore
|
||||
from k1link.observatory.portable_replay import PortableReplayService
|
||||
from k1link.observatory.portable_result_view import PortableResultViewError
|
||||
from k1link.observatory.recorded_jobs import (
|
||||
ObservatoryRecordedJobQueue,
|
||||
ObservatoryRecordedQueueError,
|
||||
)
|
||||
|
||||
ResultId = Annotated[str, Path(pattern=r"^m49-tgs-portable-review-[a-f0-9]{64}$")]
|
||||
ResultId = Annotated[
|
||||
str,
|
||||
Path(
|
||||
pattern=(
|
||||
r"^(?:m49-tgs-portable-review|lab-v1-eomt-ddrnet|"
|
||||
r"ai-layer-(?:ddrnet|eomt|rf-detr|object-distance))-[a-f0-9]{64}$"
|
||||
)
|
||||
),
|
||||
]
|
||||
BaseSha = Annotated[str, Path(pattern=r"^[a-f0-9]{64}$")]
|
||||
_LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
|
||||
def build_portable_replay_router(
|
||||
service: PortableReplayService,
|
||||
*,
|
||||
composition_runs: CompositionRunStore | None = None,
|
||||
queue: ObservatoryRecordedJobQueue | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(tags=["observatory"])
|
||||
path = "/api/v1/observatory/portable-results/{result_id}/replays/{base_sha}/recording.rrd"
|
||||
|
||||
@@ -72,4 +90,85 @@ def build_portable_replay_router(service: PortableReplayService) -> APIRouter:
|
||||
},
|
||||
)
|
||||
|
||||
composition_path = (
|
||||
"/api/v1/observatory/ai-composition-runs/{run_id}/replays/{base_sha}/recording.rrd"
|
||||
)
|
||||
|
||||
def composition_members(run_id: str) -> tuple[str, ...]:
|
||||
if composition_runs is None or queue is None:
|
||||
raise HTTPException(503, "Составной просмотр AI-слоя недоступен.")
|
||||
try:
|
||||
run = composition_runs.get(run_id)
|
||||
jobs = tuple(queue.get(job_id) for job_id in run.job_ids)
|
||||
except (CompositionRunError, ObservatoryRecordedQueueError, ValueError) as exc:
|
||||
raise HTTPException(409, "Составной запуск AI-слоя недоступен.") from exc
|
||||
if not jobs or any(
|
||||
job.state != "succeeded"
|
||||
or job.publication_state != "published"
|
||||
or job.result_id is None
|
||||
for job in jobs
|
||||
):
|
||||
raise HTTPException(409, "Расчёт всех модулей этой конфигурации ещё не завершён.")
|
||||
return tuple(job.result_id for job in jobs if job.result_id is not None)
|
||||
|
||||
@router.head(composition_path)
|
||||
def prepare_composition(
|
||||
run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
|
||||
base_sha: BaseSha,
|
||||
) -> Response:
|
||||
try:
|
||||
artifact = service.prepare_composition(run_id, composition_members(run_id), base_sha)
|
||||
except (
|
||||
ValueError,
|
||||
OSError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
CanonicalLabOverlayError,
|
||||
PortableResultViewError,
|
||||
) as exc:
|
||||
_LOG.exception("Composition replay packaging rejected")
|
||||
raise HTTPException(
|
||||
409, "Составной результат не удалось подготовить к просмотру."
|
||||
) from exc
|
||||
return Response(
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
headers={
|
||||
"Content-Length": str(artifact.byte_length),
|
||||
"ETag": f'"{artifact.sha256}"',
|
||||
"X-Rerun-Format": "RRF2",
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
)
|
||||
|
||||
@router.get(composition_path)
|
||||
def read_composition(
|
||||
run_id: Annotated[str, Path(pattern=r"^ai-composition-[a-f0-9]{64}$")],
|
||||
base_sha: BaseSha,
|
||||
generation: Annotated[str, Query(pattern=r"^[a-f0-9]{64}$")],
|
||||
) -> FileResponse:
|
||||
try:
|
||||
artifact = service.cached_composition(run_id, composition_members(run_id), base_sha)
|
||||
except (
|
||||
ValueError,
|
||||
OSError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
PortableResultViewError,
|
||||
) as exc:
|
||||
raise HTTPException(409, "Кэш составного результата не прошёл проверку.") from exc
|
||||
if artifact is None:
|
||||
raise HTTPException(409, "Составной просмотр ещё не подготовлен.")
|
||||
if artifact.sha256 != generation:
|
||||
raise HTTPException(412, "Версия составного просмотра изменилась.")
|
||||
return FileResponse(
|
||||
artifact.path,
|
||||
media_type="application/vnd.rerun.rrd",
|
||||
headers={
|
||||
"ETag": f'"{artifact.sha256}"',
|
||||
"X-Rerun-Format": "RRF2",
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
+117
-13
@@ -49,7 +49,10 @@ from k1link.viewer.recorded import (
|
||||
from k1link.viewer.recorded import (
|
||||
RecordedBlueprintError,
|
||||
recorded_blueprint_rrd,
|
||||
recorded_blueprint_sessions,
|
||||
)
|
||||
from k1link.viewer.recorded_blueprint_lifecycle import BlueprintSessionReleased
|
||||
from k1link.viewer.recorded_camera_bounds import recorded_orbital_radius_limit
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
|
||||
DEFAULT_REPLAY_ACTION_ID = "stream.start-replay"
|
||||
@@ -102,7 +105,7 @@ class ReplayRequest(StrictApiModel):
|
||||
loop: bool = False
|
||||
|
||||
|
||||
class RecordedBlueprintRequest(StrictApiModel):
|
||||
class RecordedBlueprintIdentity(StrictApiModel):
|
||||
application_id: Literal["nodedc_mission_core_recorded"]
|
||||
recording_id: str = Field(
|
||||
min_length=1,
|
||||
@@ -114,25 +117,58 @@ class RecordedBlueprintRequest(StrictApiModel):
|
||||
max_length=32,
|
||||
pattern=r"^[a-f0-9]{32}$",
|
||||
)
|
||||
accumulation_seconds: float = Field(strict=True, ge=0.0, le=3600.0)
|
||||
|
||||
|
||||
class RecordedBlueprintLifecycleRequest(RecordedBlueprintIdentity):
|
||||
action: Literal["renew", "release"]
|
||||
|
||||
|
||||
EyeCoordinate = Annotated[float, Field(strict=True, allow_inf_nan=False)]
|
||||
EyeVector = tuple[EyeCoordinate, EyeCoordinate, EyeCoordinate]
|
||||
|
||||
|
||||
class RecordedBlueprintRequest(RecordedBlueprintIdentity):
|
||||
accumulation_seconds: float = Field(strict=True, ge=0.0, allow_inf_nan=False)
|
||||
show_points: StrictBool
|
||||
show_trajectory: StrictBool
|
||||
show_grid: StrictBool
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, le=32.0)
|
||||
point_size: float = Field(default=2.5, strict=True, ge=0.1, allow_inf_nan=False)
|
||||
color_mode: Literal["intensity", "height", "distance", "rgb", "class"] = "intensity"
|
||||
palette: Literal["turbo", "viridis", "plasma", "grayscale", "custom"] = "turbo"
|
||||
custom_color: str = Field(default="#f7f8f4", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
active_view: Literal["spatial", "perception", "perception3d", "metrics"] = "spatial"
|
||||
view_reset_generation: Literal[0, 1] = 0
|
||||
unified_perception: StrictBool = False
|
||||
unified_camera_share: float = Field(strict=True, ge=0.1, le=0.9, default=0.46)
|
||||
semantic_layer: Literal["city", "vegetation"] | None = None
|
||||
plan_view: StrictBool = False
|
||||
show_detections_2d: StrictBool = False
|
||||
show_camera_image: StrictBool = True
|
||||
show_segmentation: StrictBool = False
|
||||
show_cuboids_3d: StrictBool = False
|
||||
show_costmap: StrictBool = False
|
||||
reactivate_updates: StrictBool = False
|
||||
follow_trajectory: StrictBool = False
|
||||
eye_position: EyeVector | None = None
|
||||
eye_look_target: EyeVector | None = None
|
||||
eye_up: EyeVector | None = None
|
||||
current_time_ns: int | None = Field(default=None, strict=True, ge=0, le=MAX_SAFE_INTEGER)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_eye_vectors(self) -> RecordedBlueprintRequest:
|
||||
vectors = (self.eye_position, self.eye_look_target, self.eye_up)
|
||||
if any(vector is None for vector in vectors):
|
||||
if not all(vector is None for vector in vectors):
|
||||
raise ValueError("all eye vectors must be supplied together")
|
||||
return self
|
||||
assert self.eye_position is not None
|
||||
assert self.eye_look_target is not None
|
||||
assert self.eye_up is not None
|
||||
if self.eye_position == self.eye_look_target:
|
||||
raise ValueError("eye position and look target must differ")
|
||||
if sum(value * value for value in self.eye_up) <= 1.0e-12:
|
||||
raise ValueError("eye up vector must be non-zero")
|
||||
return self
|
||||
|
||||
|
||||
class RecordedPerceptionRequest(StrictApiModel):
|
||||
@@ -365,6 +401,7 @@ def build_session_router(
|
||||
cursor: str | None = Query(default=None, max_length=128),
|
||||
scope: Literal["all", "source", "laboratory"] = "all",
|
||||
lab_contract: Literal["v1", "v2", "v3"] = "v1",
|
||||
pagination: Literal["cursor-v1"] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
_refresh_catalog(catalog_refresher)
|
||||
@@ -375,6 +412,14 @@ def build_session_router(
|
||||
include_capability_projections=lab_contract in ("v2", "v3"),
|
||||
)
|
||||
return {
|
||||
**(
|
||||
{
|
||||
"schema_version": "missioncore.observation-session-page/v1",
|
||||
"next_cursor": page.next_cursor,
|
||||
}
|
||||
if pagination == "cursor-v1"
|
||||
else {}
|
||||
),
|
||||
"items": [
|
||||
{
|
||||
"id": item.session_id,
|
||||
@@ -391,9 +436,7 @@ def build_session_router(
|
||||
else item.capture_attestation.as_dict()
|
||||
),
|
||||
**(
|
||||
{
|
||||
"lab": lab_catalog_document(item, lab_contract)
|
||||
}
|
||||
{"lab": lab_catalog_document(item, lab_contract)}
|
||||
if item.lab is not None
|
||||
else {}
|
||||
),
|
||||
@@ -411,7 +454,7 @@ def build_session_router(
|
||||
}
|
||||
for item in page.items
|
||||
if item.started_at_utc is not None
|
||||
]
|
||||
],
|
||||
}
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
@@ -870,9 +913,7 @@ def build_session_router(
|
||||
**response_kwargs,
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame"
|
||||
)
|
||||
@router.get("/api/v1/observation-sessions/{session_id}/canonical-lab/spatial-frame")
|
||||
async def get_observation_session_canonical_lab_spatial_frame(
|
||||
session_id: str,
|
||||
generation: Annotated[str, Query(min_length=64, max_length=64)],
|
||||
@@ -937,20 +978,37 @@ def build_session_router(
|
||||
headers={
|
||||
"Cache-Control": "private, max-age=31536000, immutable",
|
||||
"ETag": (
|
||||
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:'
|
||||
f'{payload["source_time_ns"]}"'
|
||||
f'"{generation}:{CANONICAL_LAB_SPATIAL_PROFILE}:{payload["source_time_ns"]}"'
|
||||
),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
)
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint-lifecycle")
|
||||
async def update_recorded_blueprint_lifecycle(
|
||||
session_id: str,
|
||||
request: RecordedBlueprintLifecycleRequest,
|
||||
) -> Response:
|
||||
# Releasing an ephemeral owner must still work after source removal.
|
||||
# The random viewport identity authorizes only its own memory resource;
|
||||
# this route never materializes or deletes recordings/artifacts.
|
||||
if not SAFE_SOURCE_ID.fullmatch(session_id):
|
||||
raise HTTPException(status_code=422, detail="Некорректный идентификатор сессии.")
|
||||
key = (request.application_id, request.recording_id, request.blueprint_session_id)
|
||||
if request.action == "release":
|
||||
await run_in_threadpool(recorded_blueprint_sessions.release, key)
|
||||
else:
|
||||
await run_in_threadpool(recorded_blueprint_sessions.renew, key)
|
||||
return Response(status_code=204, headers={"Cache-Control": "no-store"})
|
||||
|
||||
@router.post("/api/v1/observation-sessions/{session_id}/blueprint.rrd")
|
||||
async def get_observation_session_blueprint(
|
||||
session_id: str,
|
||||
request: RecordedBlueprintRequest,
|
||||
) -> Response:
|
||||
camera_max_orbital_radius: float | None = None
|
||||
try:
|
||||
await run_in_threadpool(
|
||||
command = await run_in_threadpool(
|
||||
_prepare_replay,
|
||||
store,
|
||||
catalog_refresher,
|
||||
@@ -977,19 +1035,55 @@ def build_session_router(
|
||||
active_view=request.active_view,
|
||||
view_reset_generation=request.view_reset_generation,
|
||||
unified_perception=request.unified_perception,
|
||||
unified_camera_share=request.unified_camera_share,
|
||||
semantic_layer=request.semantic_layer,
|
||||
plan_view=request.plan_view,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_camera_image=request.show_camera_image,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
show_costmap=request.show_costmap,
|
||||
reactivate_updates=request.reactivate_updates,
|
||||
follow_trajectory=request.follow_trajectory,
|
||||
eye_position=request.eye_position,
|
||||
eye_look_target=request.eye_look_target,
|
||||
eye_up=request.eye_up,
|
||||
)
|
||||
if request.current_time_ns is not None:
|
||||
camera_recording = None
|
||||
if recording_preparation_manager is not None:
|
||||
snapshot = recording_preparation_manager.status(session_id)
|
||||
if (
|
||||
snapshot is not None
|
||||
and snapshot.state == "ready"
|
||||
and snapshot.recording is not None
|
||||
):
|
||||
camera_recording = snapshot.recording
|
||||
# A composition replay consumes the immutable base launch but
|
||||
# does not GET its recording. Its short launch reservation can
|
||||
# therefore expire while the combined RRD remains open. Restore
|
||||
# the already-published base descriptor with bounded stat checks
|
||||
# so later layer toggles still receive the native zoom limit.
|
||||
if camera_recording is None and recording_materializer is not None:
|
||||
camera_recording = await run_in_threadpool(
|
||||
recording_materializer.restore_published,
|
||||
command,
|
||||
)
|
||||
if camera_recording is not None:
|
||||
camera_max_orbital_radius = await run_in_threadpool(
|
||||
recorded_orbital_radius_limit,
|
||||
camera_recording.path,
|
||||
current_time_ns=request.current_time_ns,
|
||||
accumulation_seconds=request.accumulation_seconds,
|
||||
show_points=request.show_points,
|
||||
show_trajectory=request.show_trajectory,
|
||||
)
|
||||
except SessionNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (SessionNotReplayableError, SessionIntegrityError) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except BlueprintSessionReleased as exc:
|
||||
raise HTTPException(status_code=410, detail="Сессия визуализатора закрыта.") from exc
|
||||
except RecordedBlueprintError as exc:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
@@ -1007,6 +1101,16 @@ def build_session_router(
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Content-Disposition": 'inline; filename="blueprint.rrd"',
|
||||
**(
|
||||
{
|
||||
"X-MissionCore-Camera-Max-Orbital-Radius": format(
|
||||
camera_max_orbital_radius,
|
||||
".9g",
|
||||
)
|
||||
}
|
||||
if camera_max_orbital_radius is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user