From 17bfdfb4867ff83e9c090b200327551db5eac498 Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Wed, 22 Jul 2026 14:58:17 +0300 Subject: [PATCH 1/2] fix(routing): prioritize parking near destination --- src/routers/routing.py | 35 +++++++- tests/test_routing.py | 184 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 8 deletions(-) diff --git a/src/routers/routing.py b/src/routers/routing.py index 4cc24fb..2d0f367 100644 --- a/src/routers/routing.py +++ b/src/routers/routing.py @@ -62,6 +62,12 @@ WALKING_SPEED_METERS_PER_SECOND = 1.35 WALKING_DETOUR_FACTOR = 1.35 +# После того как у парковки есть достаточный запас (два места), лишние места +# дают быстро убывающую пользу. Для маршрута к точке назначения важнее не +# заставлять человека идти несколько кварталов ради избыточного запаса мест. +SUFFICIENT_FREE_SPACES = 2 +DESTINATION_WALK_COST_MULTIPLIER = 2.5 + FORECAST_LOOKAROUND = timedelta(hours=2) PUBLIC_ROUTING_USER_ID_ENV = "PUBLIC_ROUTING_USER_ID" @@ -1338,8 +1344,14 @@ def _availability_bonus_seconds( effective_free_count: int, availability_strength: float, probability_free_space: float, + prioritize_destination: bool, ) -> float: - free_bonus = min(max(effective_free_count - 1, 0) * 220.0, 900.0) + free_count_for_bonus = effective_free_count + + if prioritize_destination: + free_count_for_bonus = min(free_count_for_bonus, SUFFICIENT_FREE_SPACES) + + free_bonus = min(max(free_count_for_bonus - 1, 0) * 220.0, 900.0) strength_bonus = availability_strength * 300.0 probability_bonus = probability_free_space * 220.0 @@ -1480,17 +1492,19 @@ def _build_ranking_context( effective_free_count=effective_free, availability_strength=availability_strength, probability_free_space=probability, + prioritize_destination=routed.duration_to_destination_seconds is not None, ) cluster_bonus = _cluster_bonus_seconds(cluster.cluster_strength) walk_seconds = routed.duration_to_destination_seconds or 0 - # Главная база стоимости — время/расстояние до парковки. - # Остальные факторы — секунды штрафа/бонуса. + # В режиме маршрута к точке назначения пешая часть имеет повышенную цену: + # парковка в нескольких кварталах не должна выигрывать у парковки прямо у + # цели только благодаря десяткам избыточных свободных мест. base_cost = ( float(routed.duration_from_origin_seconds) - + 0.55 * float(walk_seconds) + + DESTINATION_WALK_COST_MULTIPLIER * float(walk_seconds) ) generalized_cost = ( @@ -1582,6 +1596,19 @@ def _apply_peer_availability_penalties(contexts: list[_CandidateContext]) -> Non candidate = context.candidate penalty = 0.0 + # Два надёжно доступных места уже образуют достаточный запас. Не + # штрафуем такую парковку у самой точки назначения только потому, что + # в нескольких кварталах есть гораздо более крупная парковка. + has_sufficient_destination_availability = ( + candidate.duration_to_destination_seconds is not None + and candidate.current_free_count >= SUFFICIENT_FREE_SPACES + and context.effective_free_count >= SUFFICIENT_FREE_SPACES + and context.availability_probability >= 0.40 + ) + + if has_sufficient_destination_availability: + continue + for other in contexts: if other is context: continue diff --git a/tests/test_routing.py b/tests/test_routing.py index 82b791a..a727a33 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -36,8 +36,14 @@ def json(self): return self._payload -def _target(zone_id: int, distance: int | None = None, free: int = 4): +def _target( + zone_id: int, + distance: int | None = None, + free: int = 4, + capacity: int | None = None, +): point = GeoPoint(latitude=55.75 + zone_id * 0.00005, longitude=37.61) + capacity = capacity if capacity is not None else max(10, free) zone = SimpleNamespace( parking_zone_id=zone_id, camera_id=None, @@ -46,8 +52,8 @@ def _target(zone_id: int, distance: int | None = None, free: int = 4): location_type=None, is_accessible=False, pay=0, - capacity=10, - occupied=10 - free, + capacity=capacity, + occupied=capacity - free, confidence=0.9, occupancy_updated_at=datetime.now(timezone.utc), ) @@ -55,7 +61,7 @@ def _target(zone_id: int, distance: int | None = None, free: int = 4): zone=zone, point=point, anchor_distance_meters=distance if distance is not None else zone_id * 10, - current_occupied=10 - free, + current_occupied=capacity - free, current_free_count=free, current_confidence=0.9, ) @@ -292,5 +298,175 @@ def test_latest_generation_is_selected_in_sql(self) -> None: self.assertIn("generation_rank", sql) +class DestinationRankingTests(unittest.TestCase): + @staticmethod + def _context( + target, + *, + drive_seconds: int, + walk_seconds: int | None, + request_context: routing._RankingRequestContext, + use_forecast: bool = False, + ) -> routing._CandidateContext: + routed = routing._RoutedCandidate( + zone_target=target, + distance_from_origin_meters=drive_seconds * 8, + duration_from_origin_seconds=drive_seconds, + distance_to_destination_meters=walk_seconds, + duration_to_destination_seconds=walk_seconds, + arrival_time=datetime(2026, 1, 1, 12, tzinfo=timezone.utc), + ) + return routing._build_ranking_context( + routed=routed, + request_context=request_context, + use_forecast=use_forecast, + ) + + @staticmethod + def _request_context(targets) -> routing._RankingRequestContext: + return routing._RankingRequestContext( + forecasts_by_zone={}, + cluster_neighbors=routing._build_cluster_neighbors(targets, targets), + effective_state_cache={}, + ) + + def test_four_spaces_at_destination_beat_twenty_spaces_five_minutes_away(self) -> None: + near = _target(1, free=4, capacity=10) + far = _target(2, free=20, capacity=30) + request_context = self._request_context([near, far]) + + ranked = routing._finalize_contexts([ + self._context( + near, + drive_seconds=600, + walk_seconds=20, + request_context=request_context, + ), + self._context( + far, + drive_seconds=600, + walk_seconds=300, + request_context=request_context, + ), + ]) + + self.assertEqual([candidate.zone_id for candidate in ranked], [1, 2]) + self.assertEqual( + ranked[0].ranking_explanation.peer_better_availability_penalty_seconds, + 0.0, + ) + + def test_two_spaces_at_destination_beat_large_excess_supply_farther_away(self) -> None: + near = _target(1, free=2, capacity=10) + far = _target(2, free=100, capacity=120) + request_context = self._request_context([near, far]) + + ranked = routing._finalize_contexts([ + self._context( + near, + drive_seconds=600, + walk_seconds=20, + request_context=request_context, + ), + self._context( + far, + drive_seconds=600, + walk_seconds=300, + request_context=request_context, + ), + ]) + + self.assertEqual([candidate.zone_id for candidate in ranked], [1, 2]) + + def test_single_space_at_destination_keeps_scarcity_risk(self) -> None: + near = _target(1, free=1, capacity=10) + far = _target(2, free=20, capacity=30) + request_context = routing._RankingRequestContext( + forecasts_by_zone={}, + cluster_neighbors={}, + effective_state_cache={}, + ) + + ranked = routing._finalize_contexts([ + self._context( + near, + drive_seconds=600, + walk_seconds=20, + request_context=request_context, + ), + self._context( + far, + drive_seconds=600, + walk_seconds=300, + request_context=request_context, + ), + ]) + + self.assertEqual([candidate.zone_id for candidate in ranked], [2, 1]) + + def test_destination_priority_uses_availability_at_arrival(self) -> None: + arrival = datetime(2026, 1, 1, 12, tzinfo=timezone.utc) + near = _target(1, free=4, capacity=10) + far = _target(2, free=100, capacity=120) + + near_forecast = Forecast( + forecast_id=1, + zone_id=1, + predicted_for=arrival, + generated_at=arrival - timedelta(minutes=5), + capacity=10, + predicted_occupied=8, + probability_free_space=0.7, + confidence=0.9, + model_type="test", + ) + far_forecast = Forecast( + forecast_id=2, + zone_id=2, + predicted_for=arrival, + generated_at=arrival - timedelta(minutes=5), + capacity=120, + predicted_occupied=20, + probability_free_space=0.99, + confidence=0.9, + model_type="test", + ) + + request_context = routing._RankingRequestContext( + forecasts_by_zone={ + 1: routing._ForecastSeries( + forecasts=[near_forecast], + predicted_timestamps=[routing._datetime_timestamp(arrival)], + ), + 2: routing._ForecastSeries( + forecasts=[far_forecast], + predicted_timestamps=[routing._datetime_timestamp(arrival)], + ), + }, + cluster_neighbors=routing._build_cluster_neighbors([near, far], [near, far]), + effective_state_cache={}, + ) + + ranked = routing._finalize_contexts([ + self._context( + near, + drive_seconds=600, + walk_seconds=20, + request_context=request_context, + use_forecast=True, + ), + self._context( + far, + drive_seconds=600, + walk_seconds=300, + request_context=request_context, + use_forecast=True, + ), + ]) + + self.assertEqual(ranked[0].zone_id, 1) + self.assertEqual(ranked[0].ranking_explanation.effective_free_count, 2) + + if __name__ == "__main__": unittest.main() From a209e34c787dae70e73ff0486b1dd3a823ea70dd Mon Sep 17 00:00:00 2001 From: Nikita Aksenov Date: Wed, 22 Jul 2026 19:14:51 +0300 Subject: [PATCH 2/2] feat: get camera snapshots from S3 --- example.env | 18 +++- requirements.txt | 2 + src/main.py | 6 ++ src/routers/analytics.py | 157 +++++++++++++++++++++++++++++- src/routers/cameras.py | 201 ++++++++++++++++++++++----------------- src/schemas/analytics.py | 1 + 6 files changed, 293 insertions(+), 92 deletions(-) diff --git a/example.env b/example.env index 0fdf26f..a61e7af 100644 --- a/example.env +++ b/example.env @@ -25,8 +25,22 @@ ROUTING_AVERAGE_DRIVING_SPEED_KPH=30 # Super token for trusted services. Send it as Authorization: Bearer ${API_TOKEN}. #API_TOKEN=change-me -# Directory with latest camera snapshots named as {camera_id}.jpg -#CAMERAS_IMAGES_DIRECTORY_PATH=/var/lib/parktrack/camera-images +# Encrypted detection artifacts in S3 (PTSNAP01 / AES-256-GCM) +SNAPSHOT_S3_BUCKET=parktrack-snapshots +SNAPSHOT_S3_PREFIX=camera-snapshots +SNAPSHOT_ENCRYPTION_KEY_ID=snapshot-key-2026-01 +SNAPSHOT_ENCRYPTION_KEY_BASE64=replace-with-base64-encoded-32-byte-key +# JSON object with old key IDs and Base64 keys kept during key rotation. +#SNAPSHOT_DECRYPTION_KEYS_JSON={"snapshot-key-2025-01":"base64-key"} +#SNAPSHOT_S3_ENDPOINT_URL=https://s3.example.com +#SNAPSHOT_S3_REGION=ru-central1 +#SNAPSHOT_MAX_OBJECT_SIZE_BYTES=26214400 + +# boto3 also supports IAM roles and the standard AWS credential chain. +# The API identity must have s3:GetObject for SNAPSHOT_S3_PREFIX. +#AWS_ACCESS_KEY_ID=replace-me +#AWS_SECRET_ACCESS_KEY=replace-me +#AWS_SESSION_TOKEN=replace-me # Password reset # If SMTP_HOST is set, reset tokens are sent by email. diff --git a/requirements.txt b/requirements.txt index 4dab8a0..493373f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ PyJWT==2.7.0 email-validator==2.3.0 requests==2.32.5 numpy==2.2.6 +boto3>=1.34,<2 +cryptography>=42,<47 diff --git a/src/main.py b/src/main.py index 4640704..325ab2d 100644 --- a/src/main.py +++ b/src/main.py @@ -35,6 +35,12 @@ allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_methods=["*"], allow_headers=["*"], + expose_headers=[ + "Content-Disposition", + "X-Detection-Run-Id", + "X-Snapshot-Captured-At", + "X-Snapshot-Variant", + ], allow_credentials=True, ) diff --git a/src/routers/analytics.py b/src/routers/analytics.py index 7b1eaba..eac17b1 100644 --- a/src/routers/analytics.py +++ b/src/routers/analytics.py @@ -2,9 +2,10 @@ from collections import defaultdict from datetime import datetime, timezone -from typing import Annotated, Any +from typing import Annotated, Any, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import Response from sqlalchemy import bindparam, or_, text from sqlalchemy.orm import Session @@ -19,6 +20,14 @@ User, ) from ..dependencies import is_api_token_authenticated, require +from ..services.snapshot_storage import ( + SnapshotInvalidError, + SnapshotNotConfiguredError, + SnapshotNotFoundError, + SnapshotUnavailableError, + read_snapshot_from_metadata, + snapshot_reference_from_metadata, +) from ..schemas.analytics import ( AnalyticsSummary, ConfidencePoint, @@ -46,6 +55,27 @@ router = APIRouter(prefix="/admin/analytics", tags=["Admin Analytics"]) +SNAPSHOT_IMAGE_RESPONSES = { + 200: { + "description": "Decrypted JPEG snapshot", + "content": { + "image/jpeg": { + "schema": {"type": "string", "format": "binary"}, + } + }, + } +} +YOLO_LABELS_RESPONSES = { + 200: { + "description": "Decrypted YOLOv12 detection labels", + "content": { + "text/plain": { + "schema": {"type": "string", "format": "binary"}, + } + }, + } +} + GRANULARITY_SECONDS: dict[str, int] = { "5m": 5 * 60, @@ -527,6 +557,81 @@ def _ensure_detection_visible(db: Session, detection: OccupancyObservation, user ) +def _detection_camera_id(db: Session, detection: OccupancyObservation) -> int: + if detection.camera_id is not None: + return detection.camera_id + if detection.zone_id is not None: + zone = db.query(ParkingZone).filter( + ParkingZone.parking_zone_id == detection.zone_id + ).one_or_none() + if zone is not None: + return zone.camera_id + raise HTTPException( + status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error_description": "Detection run has no camera_id"}, + ) + + +def _artifact_url(detection_run_id: int, variant: str) -> str: + base = f"/api/v1/admin/analytics/detections/{detection_run_id}" + if variant == "labels": + return f"{base}/labels" + return f"{base}/snapshot?variant={variant}" + + +def _available_artifact_url( + metadata: dict[str, Any], + detection_run_id: int, + variant: str, +) -> str | None: + if snapshot_reference_from_metadata(metadata, variant) is None: + return None + return _artifact_url(detection_run_id, variant) + + +def _detection_artifact_response( + db: Session, + detection: OccupancyObservation, + variant: str, +) -> Response: + camera_id = _detection_camera_id(db, detection) + try: + artifact = read_snapshot_from_metadata( + detection.metadata_json, + camera_id=camera_id, + variant=variant, + ) + except SnapshotNotFoundError as exception: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + detail={"error_description": "Detection artifact not available"}, + ) from exception + except (SnapshotNotConfiguredError, SnapshotUnavailableError) as exception: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"error_description": "Snapshot storage is unavailable"}, + ) from exception + except SnapshotInvalidError as exception: + raise HTTPException( + status.HTTP_502_BAD_GATEWAY, + detail={"error_description": "Detection artifact is invalid"}, + ) from exception + + disposition = "attachment" if variant == "labels" else "inline" + return Response( + content=artifact.content, + media_type=artifact.content_type, + headers={ + "Content-Disposition": f'{disposition}; filename="{artifact.filename}"', + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "X-Detection-Run-Id": str(detection.observation_id), + "X-Snapshot-Captured-At": artifact.captured_at, + "X-Snapshot-Variant": variant, + }, + ) + + # --------------------------------------------------------------------------- # Time and aggregation helpers # --------------------------------------------------------------------------- @@ -800,8 +905,15 @@ def _serialize_detection_run( error_code=_to_str_or_none(_metadata_value(metadata, "error_code")), error_message=_to_str_or_none(_metadata_value(metadata, "error_message", "error")), has_feedback=observation.observation_id in feedback_ids, - raw_snapshot_url=_to_str_or_none(_metadata_value(metadata, "raw_snapshot_url", "snapshot_url")), - annotated_snapshot_url=_to_str_or_none(_metadata_value(metadata, "annotated_snapshot_url")), + raw_snapshot_url=_available_artifact_url( + metadata, observation.observation_id, "raw" + ), + annotated_snapshot_url=_available_artifact_url( + metadata, observation.observation_id, "annotated" + ), + yolo_labels_url=_available_artifact_url( + metadata, observation.observation_id, "labels" + ), ) @@ -1499,6 +1611,45 @@ def get_detection_run( return _serialize_detection_run(db, detection, feedback_ids, zone_camera_ids) +# --------------------------------------------------------------------------- +# GET /admin/analytics/detections/{detection_run_id}/snapshot +# --------------------------------------------------------------------------- + +@router.get( + "/detections/{detection_run_id}/snapshot", + response_class=Response, + responses=SNAPSHOT_IMAGE_RESPONSES, +) +def get_detection_snapshot( + detection_run_id: int, + current_user: Annotated[User, require("analytics.view")], + db: Annotated[Session, Depends(get_db)], + variant: Literal["raw", "annotated"] = "raw", +): + detection = _get_detection_or_404(db, detection_run_id) + _ensure_detection_visible(db, detection, current_user) + return _detection_artifact_response(db, detection, variant) + + +# --------------------------------------------------------------------------- +# GET /admin/analytics/detections/{detection_run_id}/labels +# --------------------------------------------------------------------------- + +@router.get( + "/detections/{detection_run_id}/labels", + response_class=Response, + responses=YOLO_LABELS_RESPONSES, +) +def get_detection_labels( + detection_run_id: int, + current_user: Annotated[User, require("analytics.view")], + db: Annotated[Session, Depends(get_db)], +): + detection = _get_detection_or_404(db, detection_run_id) + _ensure_detection_visible(db, detection, current_user) + return _detection_artifact_response(db, detection, "labels") + + # --------------------------------------------------------------------------- # POST /admin/analytics/detections/{detection_run_id}/feedback # --------------------------------------------------------------------------- diff --git a/src/routers/cameras.py b/src/routers/cameras.py index 4118e72..0e30a66 100644 --- a/src/routers/cameras.py +++ b/src/routers/cameras.py @@ -1,20 +1,27 @@ from __future__ import annotations -import os from io import BytesIO -from pathlib import Path from typing import Annotated import cv2 import numpy as np import requests -from fastapi import APIRouter, Depends, HTTPException, status -from fastapi.responses import FileResponse, StreamingResponse +from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi.responses import Response, StreamingResponse +from sqlalchemy import or_ from sqlalchemy.orm import Session from ..database import get_db -from ..db_models import Camera, DataSource, GlobalRole, Partner, User +from ..db_models import Camera, DataSource, GlobalRole, OccupancyObservation, Partner, User from ..dependencies import require +from ..services.snapshot_storage import ( + SnapshotInvalidError, + SnapshotNotConfiguredError, + SnapshotNotFoundError, + SnapshotUnavailableError, + read_snapshot_from_metadata, + snapshot_reference_from_metadata, +) from ..schemas.cameras import ( CameraMapItemResponse, CameraNextResponse, @@ -25,12 +32,15 @@ router = APIRouter(prefix="/cameras", tags=["Cameras"]) - -SUPPORTED_SNAPSHOT_MEDIA_TYPES = { - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".png": "image/png", - ".webp": "image/webp", +SNAPSHOT_IMAGE_RESPONSES = { + 200: { + "description": "JPEG camera snapshot", + "content": { + "image/jpeg": { + "schema": {"type": "string", "format": "binary"}, + } + }, + } } @@ -420,7 +430,15 @@ def _encode_frame_to_jpeg_response(frame) -> StreamingResponse: out = BytesIO(buffer.tobytes()) out.seek(0) - return StreamingResponse(out, media_type="image/jpeg") + return StreamingResponse( + out, + media_type="image/jpeg", + headers={ + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "X-Snapshot-Variant": "raw", + }, + ) def _get_live_raw_snapshot_response(camera: Camera) -> StreamingResponse: @@ -438,80 +456,88 @@ def _get_live_raw_snapshot_response(camera: Camera) -> StreamingResponse: return _encode_frame_to_jpeg_response(frame) -def _get_first_existing_directory(env_names: tuple[str, ...]) -> Path | None: - for env_name in env_names: - directory = os.getenv(env_name) - if directory: - return Path(directory) - - return None - - -def _find_snapshot_file(directory: Path, camera_id: int) -> tuple[Path, str] | None: - for extension, media_type in SUPPORTED_SNAPSHOT_MEDIA_TYPES.items(): - path = directory / f"{camera_id}{extension}" - - if path.is_file(): - return path, media_type - - return None - - -def _build_file_snapshot_response(path: Path, media_type: str) -> FileResponse: - return FileResponse(path, media_type=media_type) - - -def _try_get_annotated_snapshot_response(camera_id: int) -> FileResponse | None: - directory = os.getenv("CAMERAS_IMAGES_DIRECTORY_PATH") - - if directory is None: - return None - - directory = Path(directory) - - for extension, media_type in SUPPORTED_SNAPSHOT_MEDIA_TYPES.items(): - path = directory / f"{camera_id}{extension}" - - if path.is_file(): - return _build_file_snapshot_response(path, media_type) - - return None - - -def _get_last_detection_snapshot_response(camera_id: int) -> FileResponse: - directory = os.getenv("CAMERAS_IMAGES_DIRECTORY_PATH") - - if directory is None: - raise HTTPException( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - detail={"error_description": "Last detection snapshots directory is not configured"}, - ) - - directory = Path(directory) - - found = None - for extension, media_type in SUPPORTED_SNAPSHOT_MEDIA_TYPES.items(): - path = directory / f"{camera_id}_source{extension}" - - if path.is_file(): - found = path, media_type +def _stored_snapshot_response( + db: Session, + camera_id: int, + requested_variants: tuple[str, ...], + detection_run_id: int | None = None, +) -> Response: + query = db.query(OccupancyObservation).filter( + OccupancyObservation.camera_id == camera_id + ) + query = query.filter(or_(*[ + OccupancyObservation.metadata_json["snapshots"].has_key(variant) # noqa: W601 + for variant in requested_variants + ])) + if detection_run_id is not None: + query = query.filter(OccupancyObservation.observation_id == detection_run_id) + + observations = query.order_by( + OccupancyObservation.observed_at.desc(), + OccupancyObservation.observation_id.desc(), + ).limit(1).all() + + selected: tuple[OccupancyObservation, str] | None = None + for observation in observations: + for variant in requested_variants: + if snapshot_reference_from_metadata(observation.metadata_json, variant) is not None: + selected = observation, variant + break + if selected is not None: break - if found is None: + if selected is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail={"error_description": "Camera snapshot not available"}, ) - path, media_type = found - return _build_file_snapshot_response(path, media_type) + observation, variant = selected + try: + artifact = read_snapshot_from_metadata( + observation.metadata_json, + camera_id=camera_id, + variant=variant, + ) + except SnapshotNotFoundError as exception: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"error_description": "Camera snapshot not available"}, + ) from exception + except (SnapshotNotConfiguredError, SnapshotUnavailableError) as exception: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"error_description": "Snapshot storage is unavailable"}, + ) from exception + except SnapshotInvalidError as exception: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={"error_description": "Camera snapshot is invalid"}, + ) from exception + + return Response( + content=artifact.content, + media_type=artifact.content_type, + headers={ + "Content-Disposition": f'inline; filename="{artifact.filename}"', + "Cache-Control": "private, no-store", + "X-Content-Type-Options": "nosniff", + "X-Detection-Run-Id": str(observation.observation_id), + "X-Snapshot-Captured-At": artifact.captured_at, + "X-Snapshot-Variant": variant, + }, + ) # --------------------------------------------------------------------------- # GET /cameras/{camera_id}/snapshot # --------------------------------------------------------------------------- -@router.get("/{camera_id}/snapshot") +@router.get( + "/{camera_id}/snapshot", + response_class=Response, + responses=SNAPSHOT_IMAGE_RESPONSES, +) def get_snapshot( camera_id: int, current_user: Annotated[User, require("cameras.view")], @@ -519,25 +545,26 @@ def get_snapshot( annotated: bool = False, last_detection: bool = False, fallback_to_raw: bool = False, + detection_run_id: int | None = Query(None, ge=1), ): camera = _get_camera_or_404(db, camera_id) _ensure_camera_visible(camera, current_user) if annotated: - # last_detection при annotated=true игнорируется. - annotated_response = _try_get_annotated_snapshot_response(camera_id) - if annotated_response is not None: - return annotated_response - - if fallback_to_raw: - return _get_live_raw_snapshot_response(camera) - - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail={"error_description": "Camera snapshot not available"}, + variants = ("annotated", "raw") if fallback_to_raw else ("annotated",) + return _stored_snapshot_response( + db, + camera_id, + variants, + detection_run_id=detection_run_id, ) - if last_detection: - return _get_last_detection_snapshot_response(camera_id) + if last_detection or detection_run_id is not None: + return _stored_snapshot_response( + db, + camera_id, + ("raw",), + detection_run_id=detection_run_id, + ) - return _get_live_raw_snapshot_response(camera) \ No newline at end of file + return _get_live_raw_snapshot_response(camera) diff --git a/src/schemas/analytics.py b/src/schemas/analytics.py index b4920d9..c234540 100644 --- a/src/schemas/analytics.py +++ b/src/schemas/analytics.py @@ -153,6 +153,7 @@ class DetectionRun(BaseModel): has_feedback: bool raw_snapshot_url: str | None annotated_snapshot_url: str | None + yolo_labels_url: str | None class DetectionRunListResponse(BaseModel):