| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313 |
- """FastAPI REST 路由."""
- import logging
- import mimetypes
- import os
- import threading
- import time
- from pathlib import Path
- from typing import Optional
- import cv2
- from fastapi import APIRouter, Depends, HTTPException
- from fastapi.responses import StreamingResponse, FileResponse, JSONResponse
- from web.auth import verify_api_key
- from pydantic import BaseModel, Field
- import web.state as _web_state_module
- from web.state import WebState
- from core.stream_manager import generate_mjpeg_stream
- from config.coordinator import COORDINATOR_CONFIG
- router = APIRouter()
- PANORAMA_BASE = Path(os.environ.get("PANORAMA_DIR", ".")).resolve()
- class AddPointPayload(BaseModel):
- pan: float = Field(..., ge=-360, le=360)
- tilt: float = Field(..., ge=-90, le=90)
- zoom: int = Field(1, ge=1)
- dwell_time: float = Field(3.0, gt=0)
- preview_image: Optional[str] = None
- class PreviewPayload(BaseModel):
- pan: float = Field(..., ge=-360, le=360)
- tilt: float = Field(..., ge=-90, le=90)
- zoom: int = Field(1, ge=1)
- point_id: Optional[int] = None
- def _get_state() -> WebState:
- state = _web_state_module.web_state
- if state is None:
- raise HTTPException(status_code=503, detail="System not initialized")
- return state
- def _resolve_panorama_path(raw_path: Optional[str]) -> Path:
- if not raw_path:
- raise HTTPException(status_code=404, detail="Panorama not found")
- resolved = (PANORAMA_BASE / raw_path).resolve()
- try:
- resolved.relative_to(PANORAMA_BASE)
- except ValueError as exc:
- raise HTTPException(status_code=400, detail="Invalid panorama path") from exc
- return resolved
- def _sanitize_group_state(state: dict) -> dict:
- safe = dict(state)
- safe.pop("panorama_rtsp", None)
- safe.pop("ptz_rtsp", None)
- safe.pop("ptz_config", None)
- return safe
- @router.get("/api/status")
- def api_status() -> dict:
- state = _get_state()
- groups = {}
- for gid in state.group_state.list_groups():
- groups[gid] = _sanitize_group_state(state.group_state.get(gid))
- return {"groups": groups}
- def _require_group(state, group_id: str) -> dict:
- current = state.group_state.get(group_id)
- if not current:
- raise HTTPException(status_code=404, detail="Group not found")
- return current
- @router.post("/api/scan/{group_id}", dependencies=[Depends(verify_api_key)])
- def api_start_scan(group_id: str) -> JSONResponse:
- state = _get_state()
- if not state.group_state.compare_and_update(
- group_id, "polling_state", "idle", "scanning"
- ):
- current = state.group_state.get(group_id)
- if not current:
- raise HTTPException(status_code=404, detail="Group not found")
- raise HTTPException(status_code=409, detail="Scan already in progress")
- scanner = state.scanners.get(group_id)
- if not scanner:
- state.group_state.update(group_id, "polling_state", "idle")
- raise HTTPException(status_code=404, detail="Scanner not found")
- def run() -> None:
- try:
- result = scanner.run(
- pan_range=(0.0, 360.0),
- tilt_layers=tuple(float(t) for t in range(-90, 91, 30)),
- pan_step=30.0,
- zoom=1,
- progress_callback=lambda p: state.group_state.update(
- group_id, "scan_progress", p
- ),
- )
- state.scan_store.set_samples(group_id, result["samples"])
- state.scan_store.set_panorama(
- group_id,
- {
- "equirectangular": result["panorama_path"],
- "width": 4096,
- "height": 2048,
- },
- )
- state.scan_store.set_scan_config(group_id, result["config"])
- state.group_state.update(
- group_id, "scan_progress", {"state": "completed"}
- )
- except Exception as exc: # noqa: BLE001
- logging.exception("Scan failed for group %s", group_id)
- state.group_state.update(
- group_id, "scan_progress", {"state": "failed", "error": str(exc)}
- )
- finally:
- state.group_state.update(group_id, "polling_state", "idle")
- state.group_state.update(group_id, "polling_state", "scanning")
- threading.Thread(target=run, daemon=True).start()
- return JSONResponse(
- {"message": "Scan started", "group_id": group_id},
- status_code=202,
- )
- @router.get("/api/scan/{group_id}/progress")
- def api_scan_progress(group_id: str) -> dict:
- state = _get_state()
- current = _require_group(state, group_id)
- return current.get("scan_progress", {})
- @router.get("/api/points/{group_id}")
- def api_list_points(group_id: str) -> dict:
- state = _get_state()
- _require_group(state, group_id)
- return {"points": state.scan_store.list_enabled_points(group_id)}
- @router.post("/api/points/{group_id}", dependencies=[Depends(verify_api_key)])
- def api_add_point(group_id: str, payload: AddPointPayload) -> dict:
- state = _get_state()
- _require_group(state, group_id)
- point = state.scan_store.add_enabled_point(
- group_id,
- pan=payload.pan,
- tilt=payload.tilt,
- zoom=payload.zoom,
- dwell_time=payload.dwell_time,
- preview_image=payload.preview_image,
- )
- return point
- @router.delete("/api/points/{group_id}/{point_id}", dependencies=[Depends(verify_api_key)])
- def api_delete_point(group_id: str, point_id: int) -> dict:
- state = _get_state()
- _require_group(state, group_id)
- ok = state.scan_store.delete_enabled_point(group_id, point_id)
- if not ok:
- raise HTTPException(status_code=404, detail="Point not found")
- return {"ok": True}
- @router.post("/api/poll/{group_id}/start", dependencies=[Depends(verify_api_key)])
- def api_poll_start(group_id: str) -> dict:
- state = _get_state()
- current = state.group_state.get(group_id)
- if not current:
- raise HTTPException(status_code=404, detail="Group not found")
- if not state.group_state.compare_and_update(
- group_id, "polling_state", "idle", "polling"
- ):
- current_state = current.get("polling_state")
- if current_state == "scanning":
- raise HTTPException(status_code=409, detail="Scan in progress")
- if current_state == "polling":
- raise HTTPException(status_code=409, detail="Polling already active")
- raise HTTPException(status_code=409, detail="Cannot start polling")
- scheduler = state.schedulers.get(group_id)
- if not scheduler:
- state.group_state.update(group_id, "polling_state", "idle")
- raise HTTPException(status_code=404, detail="Scheduler not found")
- scheduler.start()
- return {"message": "Polling started"}
- @router.post("/api/poll/{group_id}/stop", dependencies=[Depends(verify_api_key)])
- def api_poll_stop(group_id: str) -> dict:
- state = _get_state()
- _require_group(state, group_id)
- scheduler = state.schedulers.get(group_id)
- if scheduler:
- scheduler.stop()
- state.group_state.update(group_id, "polling_state", "idle")
- return {"message": "Polling stopped"}
- @router.post("/api/preview/{group_id}", dependencies=[Depends(verify_api_key)])
- def api_preview(group_id: str, payload: PreviewPayload) -> dict:
- state = _get_state()
- _require_group(state, group_id)
- ptz = state.ptz_cameras.get(group_id)
- if not ptz:
- raise HTTPException(status_code=404, detail="PTZ camera not connected")
- try:
- ptz.goto_exact_position(payload.pan, payload.tilt, payload.zoom)
- except Exception as exc:
- logging.exception("PTZ preview move failed for %s", group_id)
- raise HTTPException(status_code=500, detail=f"PTZ move failed: {exc}") from exc
- stabilize = COORDINATOR_CONFIG.get("ptz_stabilize_time", 1.5)
- time.sleep(stabilize)
- stream = state.stream_manager.get(f"{group_id}_ptz")
- frame = stream.get_frame() if stream else None
- snapshot_path = None
- if frame is not None:
- snapshot_dir = Path("data") / "previews" / group_id
- snapshot_dir.mkdir(parents=True, exist_ok=True)
- snapshot_path = str(snapshot_dir / f"preview_{int(time.time() * 1000)}.jpg")
- if not cv2.imwrite(snapshot_path, frame):
- raise HTTPException(status_code=500, detail="Failed to save preview snapshot")
- state.group_state.update(group_id, "ptz_position", {
- "pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom
- })
- snapshot_url = f"/api/preview-image?path={snapshot_path}" if snapshot_path else None
- return {
- "message": "Preview done",
- "position": {"pan": payload.pan, "tilt": payload.tilt, "zoom": payload.zoom},
- "snapshot_path": snapshot_path,
- "snapshot_url": snapshot_url,
- }
- PREVIEW_BASE = Path("data/previews").resolve()
- @router.get("/api/preview-image")
- def api_preview_image(path: str) -> FileResponse:
- if not path:
- raise HTTPException(status_code=400, detail="Path required")
- raw = Path(path)
- if raw.is_absolute():
- raise HTTPException(status_code=400, detail="Invalid preview path")
- try:
- rel = raw.relative_to("data/previews")
- except ValueError:
- rel = raw
- resolved = (PREVIEW_BASE / rel).resolve()
- try:
- resolved.relative_to(PREVIEW_BASE)
- except ValueError as exc:
- raise HTTPException(status_code=400, detail="Invalid preview path") from exc
- if not resolved.exists():
- raise HTTPException(status_code=404, detail="Preview not found")
- media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
- return FileResponse(resolved, media_type=media_type)
- @router.get("/api/live/{camera}/{group_id}")
- def api_live(
- camera: str, group_id: str, marked: bool = False
- ) -> StreamingResponse:
- if camera not in {"panorama", "ptz"}:
- raise HTTPException(status_code=400, detail="Invalid camera type")
- state = _get_state()
- stream_id = f"{group_id}_{camera}"
- stream = state.stream_manager.get(stream_id)
- if not stream:
- raise HTTPException(status_code=404, detail="Stream not found")
- getter = stream.get_marked_frame if marked else stream.get_frame
- return StreamingResponse(
- generate_mjpeg_stream(getter),
- media_type="multipart/x-mixed-replace; boundary=frame",
- )
- @router.get("/api/panorama/{group_id}")
- def api_panorama(
- group_id: str, raw_path: Optional[str] = None
- ) -> FileResponse:
- state = _get_state()
- group = state.scan_store.get_group(group_id)
- if not group:
- raise HTTPException(status_code=404, detail="Group not found")
- path = raw_path if raw_path is not None else group.get("panorama", {}).get("equirectangular")
- resolved = _resolve_panorama_path(path)
- if not resolved.exists():
- raise HTTPException(status_code=404, detail="Panorama not found")
- media_type = mimetypes.guess_type(str(resolved))[0] or "image/jpeg"
- return FileResponse(resolved, media_type=media_type)
|