diff --git a/docs/API_ARCHITECTURE.md b/docs/API_ARCHITECTURE.md index 3c01c38..32e2a7b 100644 --- a/docs/API_ARCHITECTURE.md +++ b/docs/API_ARCHITECTURE.md @@ -17,6 +17,7 @@ 1. 业务逻辑放在 `domain/*` 或 `core/application/*`,`routes.py` 仅做 HTTP 适配。 2. 对外稳定能力使用 `/api/core/v1/*`;开发者域使用 `/api/dev/*`。 3. 同步更新契约文档:`docs/contracts/core-rest-v1.md` / `core-rest-v1_EN.md`,`dev-rest-v1.md` / `dev-rest-v1_EN.md`。 +4. 若新增调试页字段、相机遥测或分析结果 `overlay_ext`,同时更新 `docs/DEBUG_CONSOLE.md` / `DEBUG_CONSOLE_EN.md`。 开发板部署总览见 [开发指南](development/README.md)([English](development/README_EN.md))。提交前自检见下文第 5 节。 @@ -90,6 +91,7 @@ HTTP Request - `docs/contracts/core-rest-v1.md`、`docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md`、`docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md`(若涉及版本/路径策略) +7. 调试控制台字段、相机管线遥测与分析扩展字段已同步到调试文档。 ## 6) 快速验证命令 diff --git a/docs/API_ARCHITECTURE_EN.md b/docs/API_ARCHITECTURE_EN.md index 0e79548..5cd1ef6 100644 --- a/docs/API_ARCHITECTURE_EN.md +++ b/docs/API_ARCHITECTURE_EN.md @@ -17,6 +17,7 @@ For system-level architecture (core boundary, user vs developer surfaces, operat 1. Keep business logic in `domain/*` or `core/application/*`; `routes.py` handles HTTP adaptation only. 2. Stable surface: `/api/core/v1/*`; developer surface: `/api/dev/*`. 3. Update contract docs: `docs/contracts/core-rest-v1.md` / `core-rest-v1_EN.md`, `dev-rest-v1.md` / `dev-rest-v1_EN.md`. +4. When adding debug-console fields, camera telemetry, or analysis-result `overlay_ext`, also update `docs/DEBUG_CONSOLE.md` / `DEBUG_CONSOLE_EN.md`. Board deployment overview: [Development Guide](development/README_EN.md) | [中文](development/README.md). Pre-submit checks: section 5 below. @@ -90,6 +91,7 @@ Before API changes, confirm: - `docs/contracts/core-rest-v1.md` / `docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md` / `docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md` (when versioning/path policy changes) +7. Debug-console fields, camera-pipeline telemetry, and analysis extension fields are reflected in debug documentation. ## 6) Quick verification commands diff --git a/docs/DEBUG_CONSOLE.md b/docs/DEBUG_CONSOLE.md index 86e17eb..f5b5cbe 100644 --- a/docs/DEBUG_CONSOLE.md +++ b/docs/DEBUG_CONSOLE.md @@ -9,9 +9,9 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 ## 🚀 功能特性 ### 📷 实时预览 -- 15fps 实时相机预览 +- 低内存板优先的实时相机预览,目标帧率由 `preview_target_fps` 与运行时节流共同决定 - 支持启动/停止预览 -- 实时状态显示 +- 实时状态显示:采集帧率、预览帧率、曝光、消费者数量、编码器与内存压力 ### 📸 拍摄控制 - **单张拍摄**: 拍摄高质量照片并自动保存 @@ -23,6 +23,10 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 - **曝光时间**: 1ms - 100ms (微秒级调节) - **模拟增益**: 1x - 16x (0.1x步进) - **数字增益**: 1x - 4x (0.1x步进) +- **白平衡**: `auto` / `manual` / `night`,手动模式可设置红/蓝增益 +- **自动曝光上限**: `camera_auto_exposure_max_us` 控制暗场最长帧周期 +- **防闪烁与降噪**: 支持 AE flicker 与语义降噪模式 +- **预览编码器**: `auto` / `turbojpeg` / `opencv` - **实时应用**: 参数修改立即生效 - **一键重置**: 恢复到默认设置 @@ -157,6 +161,21 @@ python -m ogscope.web.app - `POST /api/dev/debug/camera/settings` - 更新相机设置 - `POST /api/dev/debug/camera/reset` - 重置到默认设置 +当前相机状态还会返回调试字段: + +| 字段 | 说明 | +|------|------| +| `sensor_target_fps` / `preview_target_fps` | 传感器与预览目标帧率 | +| `actual_capture_fps` / `actual_preview_fps` | 实测采集与预览帧率 | +| `actual_exposure_us` / `frame_duration_us` | 当前曝光与帧周期 | +| `preview_consumers` / `analysis_consumers` / `recording_consumers` | 预览、分析、录制消费者数量 | +| `jpeg_average_encode_ms` / `jpeg_cached_bytes` | JPEG 编码耗时与缓存大小 | +| `throttle_reason` | 当前节流原因,例如低内存或无消费者 | +| `process_rss_kb` / `process_swap_kb` / `cma_free_kb` | 进程内存、swap 与 CMA 可用量 | +| `preview_encoder` / `jpeg_source_format` | 当前预览编码器和输入格式 | +| `camera_driver` / `camera_backend` | 相机驱动与后端名称 | +| `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format` | 低分辨率辅助流状态 | + ### 预设管理 - `GET /api/dev/debug/camera/presets` - 获取预设列表 - `POST /api/dev/debug/camera/presets` - 保存预设 @@ -192,6 +211,26 @@ python scripts/test_debug_console.py --test deps 2. **权限要求**: 相机访问需要适当的系统权限 3. **存储空间**: 确保有足够的存储空间保存拍摄文件 4. **网络访问**: 调试控制台通过Web界面访问,确保网络连接正常 +5. **32 位系统**: OpenCV、SciPy、PyTurboJPEG 在 32 位系统上可能没有合适 wheel,优先使用系统包或 piwheels;低内存板建议降低预览帧率并启用自动编码器选择。 + +## 🧩 相机管线配置 + +这些配置可通过环境变量或配置文件进入运行时。名称与 `ogscope/config.py` 一致: + +| 配置 | 默认 | 说明 | +|------|------|------| +| `camera_idle_shutdown_sec` | `20.0` | 无消费者后相机热驻留时间,超时后释放采集 | +| `camera_frame_stale_timeout_sec` | `5.0` | 超过该时间没有成功帧时重新探测 | +| `camera_white_balance_mode` | `auto` | `auto` / `manual` / `night` | +| `camera_white_balance_gain_r` / `camera_white_balance_gain_b` | `1.0` | 手动白平衡红/蓝增益 | +| `camera_night_mode` | `false` | 启动时应用夜间白平衡标记 | +| `camera_auto_exposure_max_us` | `2000000` | 自动曝光最长帧周期,暗场允许降低帧率 | +| `camera_ae_flicker_mode` | `off` | `off` / `50hz` / `60hz` | +| `camera_noise_reduction_mode` | `fast` | `off` / `fast` / `high_quality` | +| `camera_lores_enabled` | `true` | 启用低分辨率辅助流统计 | +| `camera_lores_width` / `camera_lores_height` | `320` / `240` | 低分辨率辅助流尺寸 | +| `camera_lores_format` | `YUV420` | 低分辨率辅助流格式 | +| `preview_encoder` | `auto` | `auto` / `turbojpeg` / `opencv` | ## 🐛 故障排除 diff --git a/docs/DEBUG_CONSOLE_EN.md b/docs/DEBUG_CONSOLE_EN.md index 6bcf1b3..117c826 100644 --- a/docs/DEBUG_CONSOLE_EN.md +++ b/docs/DEBUG_CONSOLE_EN.md @@ -9,9 +9,9 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt ## Features ### Live preview -- ~15 fps live preview +- Low-memory-board-friendly live preview. Effective FPS is governed by `preview_target_fps` and runtime throttling. - Start/stop preview -- Live status +- Live status: capture FPS, preview FPS, exposure, consumers, encoder, and memory pressure ### Capture - **Still capture**: high-quality photos with auto-save @@ -23,6 +23,10 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt - **Exposure**: 1ms–100ms (fine steps) - **Analog gain**: 1x–16x (0.1x steps) - **Digital gain**: 1x–4x (0.1x steps) +- **White balance**: `auto` / `manual` / `night`; manual mode exposes red/blue gains +- **Auto-exposure ceiling**: `camera_auto_exposure_max_us` controls the longest dark-field frame duration +- **Flicker and noise reduction**: AE flicker and semantic noise-reduction modes +- **Preview encoder**: `auto` / `turbojpeg` / `opencv` - **Apply immediately**: changes take effect at once - **Reset**: restore defaults @@ -121,6 +125,21 @@ Browser: `http://localhost:8000/debug` - `POST /api/dev/debug/camera/settings` - `POST /api/dev/debug/camera/reset` +Camera status also exposes diagnostic fields: + +| Field | Meaning | +|-------|---------| +| `sensor_target_fps` / `preview_target_fps` | Sensor and preview target FPS | +| `actual_capture_fps` / `actual_preview_fps` | Measured capture and preview FPS | +| `actual_exposure_us` / `frame_duration_us` | Current exposure and frame duration | +| `preview_consumers` / `analysis_consumers` / `recording_consumers` | Preview, analysis, and recording consumers | +| `jpeg_average_encode_ms` / `jpeg_cached_bytes` | JPEG encode time and cached bytes | +| `throttle_reason` | Current throttle reason, for example low memory or no consumers | +| `process_rss_kb` / `process_swap_kb` / `cma_free_kb` | Process memory, swap, and CMA free memory | +| `preview_encoder` / `jpeg_source_format` | Selected preview encoder and input format | +| `camera_driver` / `camera_backend` | Camera driver and backend names | +| `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format` | Low-resolution helper stream state | + ### Presets - `GET /api/dev/debug/camera/presets` - `POST /api/dev/debug/camera/presets` @@ -147,6 +166,26 @@ python scripts/test_debug_console.py --test deps 2. **Permissions**: camera access for the service user. 3. **Disk**: ensure free space for captures. 4. **Network**: Web UI requires reachable HTTP port. +5. **32-bit OS**: OpenCV, SciPy, and PyTurboJPEG may not have suitable wheels. Prefer distro packages or piwheels, and use lower preview FPS plus automatic encoder selection on low-memory boards. + +## Camera Pipeline Configuration + +These settings enter runtime through environment variables or config files. Names match `ogscope/config.py`: + +| Setting | Default | Meaning | +|---------|---------|---------| +| `camera_idle_shutdown_sec` | `20.0` | Warm-idle timeout after the last consumer | +| `camera_frame_stale_timeout_sec` | `5.0` | Re-probe when no successful frame arrives within this duration | +| `camera_white_balance_mode` | `auto` | `auto` / `manual` / `night` | +| `camera_white_balance_gain_r` / `camera_white_balance_gain_b` | `1.0` | Manual white-balance red/blue gains | +| `camera_night_mode` | `false` | Apply night white-balance flag at startup | +| `camera_auto_exposure_max_us` | `2000000` | Longest AE frame duration for dark fields | +| `camera_ae_flicker_mode` | `off` | `off` / `50hz` / `60hz` | +| `camera_noise_reduction_mode` | `fast` | `off` / `fast` / `high_quality` | +| `camera_lores_enabled` | `true` | Enable the low-resolution helper stream | +| `camera_lores_width` / `camera_lores_height` | `320` / `240` | Low-resolution helper stream size | +| `camera_lores_format` | `YUV420` | Low-resolution helper stream format | +| `preview_encoder` | `auto` | `auto` / `turbojpeg` / `opencv` | ## Troubleshooting diff --git a/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md b/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md index 3c97466..458a17a 100644 --- a/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md +++ b/docs/architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md @@ -54,7 +54,6 @@ flowchart TD subgraph peripheralLayer["外围硬件层 / Peripheral Hardware Layer"] cameraHw["相机 / Camera IMX327"] wifiHw["网络模块 / WiFi and NetworkManager"] - gpioHw["应急GPIO / Emergency GPIO"] magnetometerHw["磁力计(规划) / Magnetometer (Planned)"] gpsHw["GPS(规划) / GPS (Planned)"] gyroHw["陀螺仪(规划) / Gyroscope (Planned)"] diff --git a/docs/contracts/core-rest-v1.md b/docs/contracts/core-rest-v1.md index 3d8ff13..8677dc6 100644 --- a/docs/contracts/core-rest-v1.md +++ b/docs/contracts/core-rest-v1.md @@ -55,18 +55,20 @@ - `GET /api/core/v1/system/status` - 响应: - `success: bool` - - `health: str` + - `health: str`(`healthy` | `degraded`) + - `health_reasons: string[]`(降级时的稳定原因码,如 `camera_not_connected`、`network_wifi_not_configured`;`healthy` 时为空数组) - `version: str` - `capabilities: object` - `system: object` - `camera: object`(相机在线与运行态摘要) - - `network: object`(WiFi 模式/信号/连接态) + - `network: object`(WiFi 模式/信号/连接态;含 `managed_by`、`in_health_scope`;subordinate 或最小部署未配 WiFi 时为 `delegated`,**不参与** `health`) - `sensors: object`(温度/CPU/内存等核心传感状态) ### 5) Camera Runtime & Preview (MJPEG / single-frame) - `GET /api/core/v1/camera/status` - - 返回相机连接状态、流状态与 runtime overrides + - 返回相机连接状态、流状态、runtime overrides 与可选 `ambient_hint` + - `ambient_hint` 是环境亮度建议遥测,供上层设备做显示/交互策略参考;典型字段包括 `available`、`dark_score`(0.0 明亮到 1.0 昏暗)、`lux`、`exposure_us`、`digital_gain` - `POST /api/core/v1/camera/start` - `POST /api/core/v1/camera/stop` @@ -96,4 +98,4 @@ MJPEG 连续视频流与流控状态、单帧 JPEG 预览(轮询、`since_fram - `4xx`:请求参数非法、契约字段校验失败。 - `5xx`:内部运行异常或底层能力不可用。 -- 契约版本路径固定为 `/v1/`。新增字段以可选形式扩展,不破坏既有消费者。 \ No newline at end of file +- 契约版本路径固定为 `/v1/`。新增字段以可选形式扩展,不破坏既有消费者。 diff --git a/docs/contracts/core-rest-v1_EN.md b/docs/contracts/core-rest-v1_EN.md index 4faa1de..cacae6b 100644 --- a/docs/contracts/core-rest-v1_EN.md +++ b/docs/contracts/core-rest-v1_EN.md @@ -55,17 +55,19 @@ This document defines the **minimal stable REST surface** for callers integratin - `GET /api/core/v1/system/status` - Response: - `success: bool` - - `health: str` + - `health: str` (`healthy` | `degraded`) + - `health_reasons: string[]` — stable degradation codes when not healthy (e.g. `camera_not_connected`, `network_wifi_not_configured`); empty when `healthy` - `version: str` - `capabilities: object` - `system: object` - `camera: object` — camera online and runtime summary - - `network: object` — WiFi mode / signal / connection state + - `network: object` — WiFi mode / signal / connection; includes `managed_by`, `in_health_scope`; when subordinate or minimal deploy without OGScope WiFi config, status is `delegated` and **does not** affect `health` - `sensors: object` — temperature / CPU / memory, etc. ### 5) Camera Runtime & Preview (MJPEG / single-frame) -- `GET /api/core/v1/camera/status` — connection, stream state, runtime overrides +- `GET /api/core/v1/camera/status` — connection, stream state, runtime overrides, and optional `ambient_hint` + - `ambient_hint` is advisory ambient-light telemetry for upstream display/interaction policy. Typical fields: `available`, `dark_score` (0.0 bright to 1.0 dark), `lux`, `exposure_us`, `digital_gain` - `POST /api/core/v1/camera/start` - `POST /api/core/v1/camera/stop` diff --git a/docs/contracts/dev-rest-v1.md b/docs/contracts/dev-rest-v1.md index e02bab1..1cdfa28 100644 --- a/docs/contracts/dev-rest-v1.md +++ b/docs/contracts/dev-rest-v1.md @@ -16,6 +16,41 @@ - 分析实验:`/api/dev/analysis/*` - 素材池、实验记录、离线/在线解算与参数试验 +## 调试相机状态 + +- `GET /api/dev/debug/camera/status` + - 用途:开发者调试页与板端性能排查。 + - 典型字段: + - `sensor_target_fps` / `preview_target_fps`:传感器与预览目标帧率 + - `actual_capture_fps` / `actual_preview_fps`:运行时采集与预览实际帧率 + - `actual_exposure_us` / `frame_duration_us`:曝光与帧时长遥测 + - `preview_consumers` / `analysis_consumers` / `recording_consumers`:消费者数量 + - `jpeg_average_encode_ms` / `jpeg_cached_bytes` / `jpeg_encode_failures`:JPEG 编码健康度 + - `throttle_reason`:运行时降速原因,空值表示未主动降速 + - `process_rss_kb` / `process_swap_kb` / `cma_free_kb`:低内存板排查指标 + - `preview_encoder` / `jpeg_source_format`:当前预览编码器与源格式 + - `camera_driver` / `camera_backend`:相机驱动与后端 + - `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format`:低分辨率支路状态 + +### 相机调试设置 + +- `POST /api/dev/debug/camera/settings` + - 用途:开发调试 UI 的增量设置入口,不属于稳定对外契约。 + - 近期字段包括: + - `whiteBalanceMode`、`whiteBalanceGainR`、`whiteBalanceGainB` + - `autoExposureMaxUs` + - `aeFlickerMode` + - `noiseReductionMode` + - `previewEncoder` + +## 分析实验扩展 + +- `POST /api/dev/analysis/solve/frame` +- `POST /api/dev/analysis/solve/frame_upload` + - 请求可选 `enable_polar_guide`。 + - 响应中的 `overlay_ext.polar_guide` 是实验性极轴引导叠加数据,用于开发 UI 验证,不属于 `core/v1` 稳定字段。 + - `overlay_ext.labels_topn` 与 `overlay_ext.polar_guide` 可独立存在;调用方应按可选字段处理。 + ## 文档入口 - 标准接口文档:`/docs`(默认) diff --git a/docs/contracts/dev-rest-v1_EN.md b/docs/contracts/dev-rest-v1_EN.md index 5199a2e..1bf304a 100644 --- a/docs/contracts/dev-rest-v1_EN.md +++ b/docs/contracts/dev-rest-v1_EN.md @@ -16,6 +16,41 @@ This document describes OGScope **developer-domain** APIs (internal). They are * - Analysis lab: `/api/dev/analysis/*` - Asset pool, experiment records, offline/online solving and parameter trials +## Debug camera status + +- `GET /api/dev/debug/camera/status` + - Purpose: developer console diagnostics and board-side performance triage. + - Typical fields: + - `sensor_target_fps` / `preview_target_fps`: sensor and preview target FPS + - `actual_capture_fps` / `actual_preview_fps`: runtime capture and preview FPS + - `actual_exposure_us` / `frame_duration_us`: exposure and frame-duration telemetry + - `preview_consumers` / `analysis_consumers` / `recording_consumers`: active consumers + - `jpeg_average_encode_ms` / `jpeg_cached_bytes` / `jpeg_encode_failures`: JPEG encoder health + - `throttle_reason`: runtime throttling reason; empty means no active throttling + - `process_rss_kb` / `process_swap_kb` / `cma_free_kb`: low-memory-board diagnostics + - `preview_encoder` / `jpeg_source_format`: active preview encoder and source format + - `camera_driver` / `camera_backend`: camera driver and backend + - `lores_enabled` / `lores_available` / `lores_width` / `lores_height` / `lores_format`: low-resolution stream status + +### Debug camera settings + +- `POST /api/dev/debug/camera/settings` + - Purpose: incremental settings endpoint for the developer UI; not part of the stable external contract. + - Recent fields include: + - `whiteBalanceMode`, `whiteBalanceGainR`, `whiteBalanceGainB` + - `autoExposureMaxUs` + - `aeFlickerMode` + - `noiseReductionMode` + - `previewEncoder` + +## Analysis lab extensions + +- `POST /api/dev/analysis/solve/frame` +- `POST /api/dev/analysis/solve/frame_upload` + - Request may include optional `enable_polar_guide`. + - `overlay_ext.polar_guide` in the response is experimental polar-guide overlay data for developer UI validation; it is not a stable `core/v1` field. + - `overlay_ext.labels_topn` and `overlay_ext.polar_guide` are independent optional fields; callers must handle either one being absent. + ## Documentation entrypoints - Standard OpenAPI: `/docs` (default) diff --git a/docs/contracts/subordinate-mode.md b/docs/contracts/subordinate-mode.md index 6dfd22a..2581ef9 100644 --- a/docs/contracts/subordinate-mode.md +++ b/docs/contracts/subordinate-mode.md @@ -42,6 +42,11 @@ OGScope 支持两种硬件平面角色: - **业务调用**:上层集成方 → OGScope `REST /api/core/v1/*`(详见 [core-rest-v1](core-rest-v1.md))。 - **传感器委托**:OGScope → 外部传感器服务 `UDS JSON-RPC`(详见 [hardware-plane-uds-v1](hardware-plane-uds-v1.md))。 +## 健康状态(health) + +- subordinate 或最小部署未配置 OGScope WiFi 脚本/连接名时,`GET /api/core/v1/system/status` 的 `network` 块为 `managed_by: external`(或 standalone 未配时为 `unconfigured`)、`status: delegated`,**不参与** `health` / `health_reasons` 计算。 +- 此时 `health` 仅反映 OGScope 职责内子系统(当前主要为相机);上层集成方应自行监控网络。 + ## 版本与兼容 - 契约以增量扩展为主;破坏性变更须更新本文档与 [core-compatibility-matrix](core-compatibility-matrix.md)。 diff --git a/docs/development/README.md b/docs/development/README.md index 6f7019a..e865e04 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -99,7 +99,7 @@ sudo journalctl -u ogscope -f | 现象 | 处理方向 | | ------------------------ | ------------------------------------------------------ | -| `ImportError: picamera2` | 用 `apt` 装相机栈;venv 由 `install.sh` 配置(**§1.2、§3**) | +| `ImportError: picamera2` | 重新跑 `install.sh` 或 `board-update.sh` 补齐相机栈;venv 由脚本配置(**§1.2、§3**) | | PEP 668 / 系统 pip 被拒 | 只用项目 `.venv`,勿在系统 Python 上混装(**§1.2**) | | 服务无法启动 | 查 `WorkingDirectory`、`ExecStart`、`journalctl`(**§10**) | @@ -169,6 +169,7 @@ chmod +x scripts/install.sh ## 2. 系统环境依赖(重点) OGScope 除 Python 包依赖外,还依赖开发板系统层的相机生态(如 `picamera2`/`libcamera`)。 +`scripts/install.sh` 与 `scripts/board-update.sh` 都会尝试补装 `python3-picamera2` 与可用的 `rpicam/libcamera` 工具;若目标板不是 IMX327,可用 `OGSCOPE_CAMERA=skip` 跳过 boot overlay 写入。 建议系统具备以下基础组件(按发行版实际包名调整): @@ -316,7 +317,9 @@ sudo journalctl -u ogscope -f - 若仅前端模板/静态文件变更,通常不需要 `poetry install` - 若服务文件配置有改动,需先 `sudo systemctl daemon-reload` +- 脚本会补齐 Picamera2/libcamera 相机运行栈;无 TTY 或 `OGSCOPE_NONINTERACTIVE=1` 时默认写入 IMX327 boot overlay,可用 `OGSCOPE_CAMERA=skip` 或 `OGSCOPE_SKIP_BOOT_CAMERA=1` 跳过 - 脚本会同步主服务 `ExecStart` 与已安装的 `**ogscope-network-boot.service**` 内 `ExecStart`(项目目录变更时);未安装开机单元则跳过 +- `scripts/sync_board_code.sh` 是开发机到开发板的便捷同步脚本:通过 `rsync` 上传源码后在板端执行 `scripts/board-update.sh`,并保留 `uploads/`、`logs/`、`data/` 等运行数据。它适合频繁迭代;全量重装、系统依赖变化或服务单元首次安装仍应使用 `install.sh` / `bootstrap.sh`。 ### 6.3 卸载服务与本地环境(`scripts/uninstall.sh`) @@ -465,6 +468,7 @@ router.include_router(new_router, tags=["NewModule - 新模块"]) - `docs/contracts/core-rest-v1.md`、`docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md`、`docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md`(段内中英,单文件) +6. 若新增或变更调试页字段、相机管线遥测、分析结果 `overlay_ext`,同步更新 `docs/DEBUG_CONSOLE.md` / `docs/DEBUG_CONSOLE_EN.md` 与对应契约文档。 ## 10. 常见故障排查 @@ -495,4 +499,3 @@ sudo journalctl -u ogscope -f # ./scripts/uninstall.sh # OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh ``` - diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 593b792..17e92e5 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -93,7 +93,7 @@ sudo journalctl -u ogscope -f | Symptom | Where to look | | ------------------------ | ---------------------------------------------------------------------- | -| `ImportError: picamera2` | Install camera stack with `apt`; venv from `install.sh` (**§1.2, §3**) | +| `ImportError: picamera2` | Re-run `install.sh` or `board-update.sh` to repair camera stack; scripts configure the venv (**§1.2, §3**) | | PEP 668 | Use project `.venv` only; do not mix into system Python (**§1.2**) | | Service fails to start | `WorkingDirectory`, `ExecStart`, `journalctl` (**§10**) | @@ -168,6 +168,9 @@ chmod +x scripts/install.sh OGScope depends on board-level camera stack (`picamera2`/`libcamera`) in addition to Poetry packages. +`scripts/install.sh` and `scripts/board-update.sh` both try to install +`python3-picamera2` and available `rpicam/libcamera` tools. If the target board +is not IMX327, set `OGSCOPE_CAMERA=skip` to skip the boot overlay write. Typical requirements: @@ -316,7 +319,9 @@ Notes: - if only templates/static files changed, `poetry install` is usually not needed - if service file changed, run `sudo systemctl daemon-reload` first +- the script repairs the Picamera2/libcamera camera runtime; without a TTY or with `OGSCOPE_NONINTERACTIVE=1`, it writes the IMX327 boot overlay by default. Use `OGSCOPE_CAMERA=skip` or `OGSCOPE_SKIP_BOOT_CAMERA=1` to skip it - the script syncs `**ExecStart**` for the main `ogscope` unit and, if installed, `**ogscope-network-boot.service**` (when the project directory path changed); if the boot unit was never installed, that step is skipped +- `scripts/sync_board_code.sh` is the developer-machine-to-board convenience sync: it uploads source with `rsync`, then runs `scripts/board-update.sh` on the board while preserving runtime data such as `uploads/`, `logs/`, and `data/`. Use it for frequent iteration; use `install.sh` / `bootstrap.sh` for full reinstall, system dependency changes, or first-time service-unit installation. ### 6.3 Uninstall service and local environment (`scripts/uninstall.sh`) @@ -461,6 +466,7 @@ To reduce mistaken submissions as the architecture grows, verify all items below - `docs/contracts/core-rest-v1.md` / `docs/contracts/core-rest-v1_EN.md` - `docs/contracts/dev-rest-v1.md` / `docs/contracts/dev-rest-v1_EN.md` - `docs/contracts/core-compatibility-matrix.md` (inline bilingual, single file) +6. If debug-console fields, camera-pipeline telemetry, or analysis-result `overlay_ext` changes, update `docs/DEBUG_CONSOLE.md` / `docs/DEBUG_CONSOLE_EN.md` and the matching contract docs. ## 10. Troubleshooting Checklist @@ -487,4 +493,3 @@ sudo journalctl -u ogscope -f # ./scripts/uninstall.sh # OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh ``` - diff --git a/ogscope/algorithms/plate_solve/centroid_quality.py b/ogscope/algorithms/plate_solve/centroid_quality.py index f2c0b88..b6fd5b6 100644 --- a/ogscope/algorithms/plate_solve/centroid_quality.py +++ b/ogscope/algorithms/plate_solve/centroid_quality.py @@ -67,7 +67,9 @@ def _reject_dense_clusters( return kept, removed, removed_pts -def _point_line_dist(points_yx: np.ndarray, p0: np.ndarray, p1: np.ndarray) -> np.ndarray: +def _point_line_dist( + points_yx: np.ndarray, p0: np.ndarray, p1: np.ndarray +) -> np.ndarray: """点到线段距离(像素)/ Distance from points to segment.""" # segment vector vx = p1[1] - p0[1] @@ -221,9 +223,11 @@ def filter_centroids_yx( ) n1 = int(xy3.shape[0]) - rejected_pts = np.concatenate( - [dense_removed, line_removed], axis=0 - ) if (dense_removed.size > 0 or line_removed.size > 0) else np.empty((0, 2), dtype=np.float64) + rejected_pts = ( + np.concatenate([dense_removed, line_removed], axis=0) + if (dense_removed.size > 0 or line_removed.size > 0) + else np.empty((0, 2), dtype=np.float64) + ) quality: dict[str, Any] = { "level": lv, "flags": flags, diff --git a/ogscope/algorithms/plate_solve/sensor_context.py b/ogscope/algorithms/plate_solve/sensor_context.py new file mode 100644 index 0000000..c86adab --- /dev/null +++ b/ogscope/algorithms/plate_solve/sensor_context.py @@ -0,0 +1,215 @@ +"""Sensor-assisted solve prediction / 传感器辅助解算预测.""" + +from __future__ import annotations + +import math +from datetime import datetime, timezone +from typing import Any + +DEFAULT_SENSOR_MATCH_THRESHOLD_DEG = 25.0 + + +def _optional_float(value: Any) -> float | None: + try: + if value is None: + return None + result = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(result): + return None + return result + + +def _normalize_deg(value: float) -> float: + return value % 360.0 + + +def _parse_utc(value: Any) -> datetime | None: + if isinstance(value, datetime): + dt = value + elif isinstance(value, str): + text = value.strip() + if not text: + return None + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + else: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def julian_date(when_utc: datetime) -> float: + """Julian date from UTC datetime / UTC 时间转儒略日.""" + dt = when_utc.astimezone(timezone.utc) + year = dt.year + month = dt.month + day = dt.day + hour = dt.hour + dt.minute / 60.0 + (dt.second + dt.microsecond / 1e6) / 3600.0 + if month <= 2: + year -= 1 + month += 12 + a = year // 100 + b = 2 - a + a // 4 + jd = int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + day + b - 1524.5 + return jd + hour / 24.0 + + +def gmst_deg(jd: float) -> float: + """Greenwich mean sidereal time in degrees / 格林尼治平恒星时(度).""" + t = (jd - 2451545.0) / 36525.0 + gmst = ( + 280.46061837 + + 360.98564736629 * (jd - 2451545.0) + + 0.000387933 * t * t + - (t * t * t) / 38710000.0 + ) + return _normalize_deg(gmst) + + +def local_sidereal_time_deg(longitude_deg: float, when_utc: datetime) -> float: + """Local sidereal time in degrees / 地方恒星时(度).""" + return _normalize_deg(gmst_deg(julian_date(when_utc)) + longitude_deg) + + +def horizontal_to_equatorial( + *, + altitude_deg: float, + azimuth_deg: float, + latitude_deg: float, + longitude_deg: float, + when_utc: datetime, +) -> tuple[float, float]: + """Convert Alt/Az to RA/Dec; azimuth is north-based clockwise. + + 地平坐标转赤道坐标;方位角从北顺时针计算。 + """ + lat_r = math.radians(latitude_deg) + alt_r = math.radians(altitude_deg) + az_r = math.radians(azimuth_deg) + sin_dec = math.sin(alt_r) * math.sin(lat_r) + math.cos(alt_r) * math.cos( + lat_r + ) * math.cos(az_r) + dec_r = math.asin(max(-1.0, min(1.0, sin_dec))) + ha_r = math.atan2( + -math.sin(az_r) * math.cos(alt_r), + math.sin(alt_r) * math.cos(lat_r) + - math.cos(alt_r) * math.sin(lat_r) * math.cos(az_r), + ) + ra_deg = _normalize_deg( + local_sidereal_time_deg(longitude_deg, when_utc) - math.degrees(ha_r) + ) + return ra_deg, math.degrees(dec_r) + + +def angular_separation_deg( + ra1_deg: float, + dec1_deg: float, + ra2_deg: float, + dec2_deg: float, +) -> float: + """Great-circle distance between two RA/Dec points / 两个赤道坐标点的大圆距离.""" + ra1 = math.radians(ra1_deg) + dec1 = math.radians(dec1_deg) + ra2 = math.radians(ra2_deg) + dec2 = math.radians(dec2_deg) + cos_sep = math.sin(dec1) * math.sin(dec2) + math.cos(dec1) * math.cos( + dec2 + ) * math.cos(ra1 - ra2) + return math.degrees(math.acos(max(-1.0, min(1.0, cos_sep)))) + + +def _as_dict(value: Any) -> dict[str, Any]: + if hasattr(value, "model_dump"): + dumped = value.model_dump(exclude_none=True) + return dumped if isinstance(dumped, dict) else {} + return value if isinstance(value, dict) else {} + + +def predict_from_solve_context( + solve_context: Any, +) -> dict[str, Any]: + """Build predicted RA/Dec from optional sensor context. + + 从可选传感器上下文生成预测赤经赤纬。 + """ + ctx = _as_dict(solve_context) + observer = _as_dict(ctx.get("observer")) + orientation = _as_dict(ctx.get("orientation")) + quality = _as_dict(ctx.get("quality")) + if not ctx: + return {"sensor_status": "unavailable"} + + gps_valid = bool(quality.get("gps_valid")) + time_valid = bool(quality.get("time_valid")) + mount_valid = bool(quality.get("mount_valid")) + heading_valid = bool(quality.get("heading_valid")) + lat = _optional_float(observer.get("latitude_deg")) + lon = _optional_float(observer.get("longitude_deg")) + when = _parse_utc(observer.get("time_utc")) + alt = _optional_float(orientation.get("altitude_deg")) + az = _optional_float(orientation.get("azimuth_deg")) + if az is None: + az = _optional_float(orientation.get("heading_deg")) + if ( + not gps_valid + or not time_valid + or lat is None + or lon is None + or when is None + or alt is None + or az is None + ): + return {"sensor_status": "unavailable"} + if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 and -90.0 <= alt <= 90.0): + return {"sensor_status": "unavailable"} + if not (mount_valid or heading_valid): + return {"sensor_status": "unavailable"} + predicted_ra, predicted_dec = horizontal_to_equatorial( + altitude_deg=alt, + azimuth_deg=az, + latitude_deg=lat, + longitude_deg=lon, + when_utc=when, + ) + return { + "predicted_ra_deg": round(predicted_ra, 6), + "predicted_dec_deg": round(predicted_dec, 6), + "sensor_delta_deg": None, + "sensor_status": "predicted", + } + + +def attach_sensor_prediction( + row: dict[str, Any], + solve_context: Any, + *, + threshold_deg: float = DEFAULT_SENSOR_MATCH_THRESHOLD_DEG, +) -> None: + """Attach sensor prediction to a solve row in-place / 就地附加传感器预测结果。""" + if solve_context is None: + return + prediction = predict_from_solve_context(solve_context) + if prediction.get("sensor_status") != "predicted": + row["sensor_prediction"] = prediction + return + ra = _optional_float(row.get("ra_deg")) + dec = _optional_float(row.get("dec_deg")) + if str(row.get("status") or "") != "MATCH_FOUND" or ra is None or dec is None: + row["sensor_prediction"] = prediction + return + delta = angular_separation_deg( + float(prediction["predicted_ra_deg"]), + float(prediction["predicted_dec_deg"]), + ra, + dec, + ) + prediction["sensor_delta_deg"] = round(delta, 6) + prediction["sensor_status"] = "matched" if delta <= threshold_deg else "mismatch" + row["sensor_prediction"] = prediction diff --git a/ogscope/algorithms/plate_solve/solver.py b/ogscope/algorithms/plate_solve/solver.py index 1ea366b..3ea4759 100644 --- a/ogscope/algorithms/plate_solve/solver.py +++ b/ogscope/algorithms/plate_solve/solver.py @@ -176,11 +176,15 @@ def subtract_large_scale_background_bgr( bg_small = cv2.GaussianBlur(small, (0, 0), sigmaX=sigma_s, sigmaY=sigma_s) bg = cv2.resize(bg_small, (w, h), interpolation=cv2.INTER_LINEAR).astype(np.float32) mean_gray = float(np.mean(gray)) - corr = gray - bg + mean_gray - corr = np.clip(corr, 1e-3, 255.0) - ratio = corr / np.maximum(gray, 1e-3) - ratio = np.clip(ratio, 0.0, 4.0) - out = frame_bgr.astype(np.float32) * ratio[..., np.newaxis] + # 复用背景数组承载校正亮度和比例,减少两张全画幅float32临时图 + # Reuse the background buffer for corrected luminance and ratio to drop two float32 frames. + np.subtract(gray, bg, out=bg) + bg += mean_gray + np.clip(bg, 1e-3, 255.0, out=bg) + np.maximum(gray, 1e-3, out=gray) + np.divide(bg, gray, out=bg) + np.clip(bg, 0.0, 4.0, out=bg) + out = frame_bgr.astype(np.float32) * bg[..., np.newaxis] return np.clip(np.round(out), 0, 255).astype(np.uint8) @@ -344,9 +348,7 @@ def solve( sorted_stars = sorted(stars, key=lambda s: s.flux, reverse=True) cyx = np.array([[s.y, s.x] for s in sorted_stars], dtype=np.float64) overlay = ( - _make_solve_overlay( - {}, cyx, None, (height, width), (height, width) - ) + _make_solve_overlay({}, cyx, None, (height, width), (height, width)) if len(cyx) > 0 else None ) diff --git a/ogscope/algorithms/star_extract/extractor.py b/ogscope/algorithms/star_extract/extractor.py index 79ebc22..66fe83a 100644 --- a/ogscope/algorithms/star_extract/extractor.py +++ b/ogscope/algorithms/star_extract/extractor.py @@ -114,9 +114,14 @@ def _extract_gray_scaled(self, gray: np.ndarray, scale: float) -> list[StarPoint continue cx = float(m["m10"] / m["m00"]) * scale cy = float(m["m01"] / m["m00"]) * scale - mask = np.zeros_like(gray, dtype=np.uint8) - cv2.drawContours(mask, [contour], -1, color=255, thickness=-1) - flux = float(cv2.mean(gray, mask=mask)[0] * area) + # 仅为轮廓包围框分配掩膜,避免每颗候选星都创建全画幅数组 + # Allocate a mask only for the contour ROI instead of one full-frame array per star. + x, y, roi_w, roi_h = cv2.boundingRect(contour) + roi = gray[y : y + roi_h, x : x + roi_w] + roi_mask = np.zeros((roi_h, roi_w), dtype=np.uint8) + shifted = contour - np.array([[[x, y]]], dtype=contour.dtype) + cv2.drawContours(roi_mask, [shifted], -1, color=255, thickness=-1) + flux = float(cv2.mean(roi, mask=roi_mask)[0] * area) points.append(StarPoint(x=cx, y=cy, flux=flux, area=area)) points.sort(key=lambda p: p.flux, reverse=True) diff --git a/ogscope/config.py b/ogscope/config.py index 604a222..9285ddc 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -94,9 +94,7 @@ class Settings(BaseSettings): camera_height: int = Field( default=720, description="图像高度 / Default capture height" ) - camera_fps: int = Field( - default=5, description="预览与调试默认帧率 / Default preview FPS" - ) + camera_fps: int = Field(default=8, description="传感器目标帧率 / Target sensor FPS") camera_sampling_mode: str = Field( default="native", description="采样模式: supersample/native/crop" ) @@ -112,6 +110,40 @@ class Settings(BaseSettings): le=2.0, description="AE 曝光补偿(档),与 camera_ae_polar_preset 联用 / AE exposure comp EV stops", ) + camera_auto_exposure_max_us: int = Field( + default=2_000_000, + ge=10_000, + le=10_000_000, + description="自动曝光最长帧周期 us,暗场允许降帧 / Max auto-exposure frame duration in us", + ) + camera_ae_flicker_mode: str = Field( + default="off", + description="AE 防闪烁模式 off/50hz/60hz / AE flicker mode: off/50hz/60hz", + ) + camera_noise_reduction_mode: str = Field( + default="fast", + description="降噪语义模式 off/fast/high_quality / Semantic noise reduction mode", + ) + camera_lores_enabled: bool = Field( + default=True, + description="启用低分辨率辅助流用于统计 / Enable lores helper stream for stats", + ) + camera_lores_width: int = Field( + default=320, + ge=64, + le=1280, + description="低分辨率辅助流宽度 / Lores helper stream width", + ) + camera_lores_height: int = Field( + default=240, + ge=48, + le=720, + description="低分辨率辅助流高度 / Lores helper stream height", + ) + camera_lores_format: str = Field( + default="YUV420", + description="低分辨率辅助流格式 / Lores helper stream format", + ) camera_flip_horizontal: bool = Field( default=False, description="相机输出水平镜像;与预览/解算同坐标系 / Camera output horizontal flip", @@ -120,6 +152,26 @@ class Settings(BaseSettings): default=False, description="相机输出垂直镜像;与预览/解算同坐标系 / Camera output vertical flip", ) + camera_white_balance_mode: str = Field( + default="auto", + description="白平衡模式 auto/manual/night / White balance mode: auto/manual/night", + ) + camera_white_balance_gain_r: float = Field( + default=1.0, + ge=0.1, + le=3.0, + description="手动白平衡红色增益 / Manual white-balance red gain", + ) + camera_white_balance_gain_b: float = Field( + default=1.0, + ge=0.1, + le=3.0, + description="手动白平衡蓝色增益 / Manual white-balance blue gain", + ) + camera_night_mode: bool = Field( + default=False, + description="启动时应用夜间白平衡标记 / Apply night white-balance mode on startup", + ) # 显示屏配置 / Display configuration display_enabled: bool = Field(default=False, description="启用 SPI 屏幕") @@ -244,8 +296,8 @@ class Settings(BaseSettings): description="大尺度背景减除:小图长边上限(像素),越小越快 / Large-scale BG downsample max side", ) star_analysis_target_fps: float = Field( - default=2 / 3, - description="星空分析目标帧率(约 1.5 秒 1 帧),仅用于前端节流 / Target star-analysis FPS for UI throttle (~1.5s per frame)", + default=0.5, + description="星空分析目标帧率(默认 2 秒 1 帧)/ Target star-analysis FPS (one frame per 2 seconds)", ) star_analysis_min_interval_ms: int = Field( default=2000, @@ -300,11 +352,15 @@ class Settings(BaseSettings): description="共享预览/MJPEG 目标帧率 / Target FPS for shared preview and MJPEG", ) preview_jpeg_quality: int = Field( - default=75, + default=65, ge=1, le=100, description="共享抓帧 JPEG 质量 / JPEG quality for shared frame grabber", ) + preview_encoder: str = Field( + default="auto", + description="预览编码器 auto/turbojpeg/opencv / Preview encoder: auto/turbojpeg/opencv", + ) debug_preview_min_interval_ms: int = Field( default=150, ge=0, @@ -327,6 +383,18 @@ class Settings(BaseSettings): "连续抓帧失败多少次后标记离线 / Consecutive grab failures before marking offline" ), ) + camera_idle_shutdown_sec: float = Field( + default=20.0, + ge=0.0, + le=300.0, + description="无消费者后相机热驻留秒数 / Camera warm-idle timeout after the last consumer", + ) + camera_frame_stale_timeout_sec: float = Field( + default=5.0, + ge=0.5, + le=60.0, + description="超过该时间无成功帧时重新探测 / Re-probe after no successful frame for this duration", + ) keep_raw_cache: bool = Field( default=False, description=( @@ -389,24 +457,6 @@ class Settings(BaseSettings): default="192.168.4.1", description="AP 模式下前端提示用的主机地址(不含端口)/ AP URL hint host without port", ) - wifi_emergency_gpio_enabled: bool = Field( - default=False, - description="启用短接 GPIO 强制切 STA / Enable GPIO short-to-STA recovery", - ) - wifi_emergency_pin_out_bcm: int = Field( - default=22, - description="应急检测:输出低电平(BCM)/ Emergency: output LOW (BCM)", - ) - wifi_emergency_pin_in_bcm: int = Field( - default=23, - description="应急检测:上拉输入(BCM)/ Emergency: input with pull-up (BCM)", - ) - wifi_emergency_hold_seconds: float = Field( - default=2.0, - ge=0.5, - le=30.0, - description="短接持续多久触发 STA / Hold time before forcing STA", - ) device_id_suffix: str = Field( default="", description="设备后缀(network.env 中 OGSCOPE_DEVICE_ID_SUFFIX)/ Device id suffix from network.env", @@ -456,6 +506,55 @@ def _parse_simulation_mode(cls, value: object) -> Optional[bool]: return False return value # type: ignore[return-value] + @field_validator("camera_white_balance_mode", mode="before") + @classmethod + def _parse_camera_white_balance_mode(cls, value: object) -> str: + """校验白平衡模式,非法值回退 auto / Validate WB mode and fall back to auto.""" + text = str(value or "auto").strip().lower() + if text in { + "auto", + "daylight", + "cloudy", + "tungsten", + "fluorescent", + "indoor", + "manual", + "night", + }: + return text + return "auto" + + @field_validator("camera_ae_flicker_mode", mode="before") + @classmethod + def _parse_camera_ae_flicker_mode(cls, value: object) -> str: + """校验防闪烁模式 / Validate AE flicker mode.""" + text = str(value or "off").strip().lower().replace("_", "") + if text in {"50", "50hz"}: + return "50hz" + if text in {"60", "60hz"}: + return "60hz" + return "off" + + @field_validator("camera_noise_reduction_mode", mode="before") + @classmethod + def _parse_camera_noise_reduction_mode(cls, value: object) -> str: + """校验降噪语义模式 / Validate semantic noise reduction mode.""" + text = str(value or "fast").strip().lower().replace("-", "_") + aliases = {"hq": "high_quality", "highquality": "high_quality", "0": "off"} + text = aliases.get(text, text) + if text in {"off", "fast", "high_quality"}: + return text + return "fast" + + @field_validator("preview_encoder", mode="before") + @classmethod + def _parse_preview_encoder(cls, value: object) -> str: + """校验预览编码器偏好 / Validate preview encoder preference.""" + text = str(value or "auto").strip().lower() + if text in {"auto", "turbojpeg", "opencv"}: + return text + return "auto" + @model_validator(mode="after") def _apply_development_mode_defaults(self) -> "Settings": """开发模式默认提升日志级别(避免与显式 WARNING/ERROR 冲突)/ Dev mode bumps log level unless explicitly quiet.""" diff --git a/ogscope/config_catalog.py b/ogscope/config_catalog.py index c0d2836..5f51249 100644 --- a/ogscope/config_catalog.py +++ b/ogscope/config_catalog.py @@ -10,7 +10,9 @@ ConfigFileScope = Literal["ogscope", "network", "both"] -_CATALOG_SECTIONS: tuple[tuple[str, str, str, ConfigFileScope, tuple[str, ...]], ...] = ( +_CATALOG_SECTIONS: tuple[ + tuple[str, str, str, ConfigFileScope, tuple[str, ...]], ... +] = ( ( "basic", "基础", @@ -66,8 +68,19 @@ "camera_gain", "camera_ae_polar_preset", "camera_ae_exposure_value", + "camera_auto_exposure_max_us", + "camera_ae_flicker_mode", + "camera_noise_reduction_mode", + "camera_lores_enabled", + "camera_lores_width", + "camera_lores_height", + "camera_lores_format", "camera_flip_horizontal", "camera_flip_vertical", + "camera_white_balance_mode", + "camera_white_balance_gain_r", + "camera_white_balance_gain_b", + "camera_night_mode", ), ), ( @@ -78,9 +91,12 @@ ( "shared_preview_fps", "preview_jpeg_quality", + "preview_encoder", "debug_preview_min_interval_ms", "camera_probe_timeout_sec", "camera_grab_failures_offline", + "camera_idle_shutdown_sec", + "camera_frame_stale_timeout_sec", "keep_raw_cache", "stream_max_mjpeg_clients", "stream_mjpeg_frame_fetch_timeout_ms", @@ -177,10 +193,6 @@ "wifi_ap_connection", "wifi_interface", "wifi_ap_url_host", - "wifi_emergency_gpio_enabled", - "wifi_emergency_pin_out_bcm", - "wifi_emergency_pin_in_bcm", - "wifi_emergency_hold_seconds", "device_id_suffix", "wifi_ap_ssid", "wifi_sta_rollback_timeout_seconds", diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index 7fe36f7..bd3db01 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -4,6 +4,7 @@ from __future__ import annotations +import math from dataclasses import dataclass from typing import Any @@ -17,8 +18,11 @@ stream_state_domain_service, ) from ogscope.domain.system.services import system_info_service -from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client from ogscope.platform.hardware.wifi_switch import wifi_switch_service +from ogscope.platform.hardware_plane.runtime import ( + describe_hardware_plane_profile, + get_hardware_plane_client, +) @dataclass(slots=True) @@ -35,18 +39,146 @@ class CoreContractService: def __init__(self) -> None: self._session = CoreAnalysisSession() + @staticmethod + def _optional_float(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _clamp01(value: float) -> float: + return max(0.0, min(1.0, value)) + + @staticmethod + def _build_ambient_hint(info: dict[str, Any], *, streaming: bool) -> dict[str, Any]: + """构造环境亮度建议遥测 / Build ambient brightness hint telemetry.""" + lux = CoreContractService._optional_float(info.get("lux")) + exposure_us = CoreContractService._optional_float( + info.get("actual_exposure_us", info.get("exposure_us")) + ) + digital_gain = CoreContractService._optional_float( + info.get("actual_digital_gain", info.get("digital_gain")) + ) + max_exposure_us = CoreContractService._optional_float( + info.get("auto_exposure_max_us", info.get("frame_duration_us")) + ) + + scores: list[float] = [] + if lux is not None and lux >= 0: + scores.append( + CoreContractService._clamp01(1.0 - math.log10(lux + 1.0) / 2.0) + ) + if exposure_us is not None and exposure_us > 0: + exposure_ceiling = max(max_exposure_us or 100_000.0, 1.0) + exposure_score = CoreContractService._clamp01( + exposure_us / exposure_ceiling + ) + gain_score = 0.0 + if digital_gain is not None: + gain_score = CoreContractService._clamp01((digital_gain - 1.0) / 7.0) + scores.append( + CoreContractService._clamp01(exposure_score * 0.75 + gain_score * 0.25) + ) + + dark_score = sum(scores) / len(scores) if scores else None + return { + "available": bool(streaming and dark_score is not None), + "source": "camera_metadata" if dark_score is not None else "unavailable", + "confidence": "live" if streaming and dark_score is not None else "stale", + "dark_score": round(dark_score, 3) if dark_score is not None else None, + "lux": lux, + "exposure_us": int(exposure_us) if exposure_us is not None else None, + "digital_gain": digital_gain, + } + @staticmethod def _normalize_camera_status(status: dict[str, Any]) -> dict[str, Any]: """统一 Core 相机状态形状 / Normalize camera status payload shape.""" + streaming = bool(status.get("streaming", False)) + info = status.get("info", {}) or {} return { "connected": bool(status.get("connected", False)), - "streaming": bool(status.get("streaming", False)), + "streaming": streaming, "recording": bool(status.get("recording", False)), - "info": status.get("info", {}) or {}, + "info": info, + "ambient_hint": CoreContractService._build_ambient_hint( + info, + streaming=streaming, + ), "runtime_overrides": status.get("runtime_overrides", {}) or {}, "error": status.get("error"), } + @staticmethod + def _network_health_in_scope(profile: dict[str, Any]) -> bool: + """网络是否纳入 OGScope health 评估 / Whether network affects OGScope health.""" + if bool(profile.get("subordinate_mode")): + return False + return wifi_switch_service.is_configured() + + @staticmethod + def _health_reasons( + camera_status: dict[str, Any], + network: dict[str, Any], + *, + network_in_health_scope: bool, + ) -> list[str]: + """稳定 health 降级原因码 / Stable machine-readable health degradation codes.""" + reasons: list[str] = [] + if not camera_status.get("connected", False): + reasons.append("camera_not_connected") + if not network_in_health_scope: + return reasons + net_err = network.get("error") + if net_err: + token = str(net_err).strip().lower().replace("-", "_") + if token and token.replace("_", "").isalnum(): + reasons.append(f"network_{token}") + else: + reasons.append("network_error") + return reasons + + @staticmethod + def _build_network_status( + profile: dict[str, Any], + system: dict[str, Any], + ) -> dict[str, Any]: + """构造 network 块:职责外仅遥测,不参与 health / Build network block with scope metadata.""" + settings = get_settings() + in_health_scope = CoreContractService._network_health_in_scope(profile) + base: dict[str, Any] = { + "wireless_interface": settings.wifi_interface, + "signal_dbm": system.get("wifi_signal_dbm"), + "quality_percent": system.get("wifi_quality"), + "in_health_scope": in_health_scope, + } + if not in_health_scope: + managed_by = ( + "external" if profile.get("subordinate_mode") else "unconfigured" + ) + return { + **base, + "managed_by": managed_by, + "status": "delegated", + "mode": "unknown", + "active_connection": None, + "ap_ipv4": None, + "error": None, + } + wifi_raw = wifi_switch_service.get_status() + return { + **base, + "managed_by": "ogscope", + "status": "managed", + "mode": wifi_raw.get("MODE", "unknown"), + "active_connection": wifi_raw.get("ACTIVE_CONNECTION"), + "ap_ipv4": wifi_raw.get("AP_IPV4"), + "error": wifi_raw.get("error"), + } + async def start_analysis( self, *, @@ -55,6 +187,7 @@ async def start_analysis( fov_estimate: float | None = None, fov_max_error: float | None = None, solve_timeout_ms: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """开始实时分析 / Start realtime analysis.""" result = await realtime_solve_service.start( @@ -63,6 +196,7 @@ async def start_analysis( fov_estimate=fov_estimate, fov_max_error=fov_max_error, solve_timeout_ms=solve_timeout_ms, + solve_context=solve_context, ) self._session.running = True return { @@ -103,10 +237,13 @@ async def stop_analysis(self) -> dict[str, Any]: async def get_system_status(self) -> dict[str, Any]: """系统状态与能力 / System status and capability map.""" - wifi_raw = wifi_switch_service.get_status() + profile = describe_hardware_plane_profile() + network_in_health_scope = self._network_health_in_scope(profile) hardware_client = get_hardware_plane_client() hw_status_resp = await hardware_client.status_get() - hw_status_data = hw_status_resp.get("data", {}) if hw_status_resp.get("success") else {} + hw_status_data = ( + hw_status_resp.get("data", {}) if hw_status_resp.get("success") else {} + ) camera_service_status = ( hw_status_data.get("services", {}).get("camera", {}) if isinstance(hw_status_data, dict) @@ -128,37 +265,35 @@ async def get_system_status(self) -> dict[str, Any]: "memory_usage_percent": system.get("memory_usage"), "uptime_seconds": system.get("uptime_seconds"), } - network = { - "mode": wifi_raw.get("MODE", "unknown"), - "wireless_interface": wifi_raw.get( - "WIRELESS_INTERFACE", get_settings().wifi_interface - ), - "signal_dbm": system.get("wifi_signal_dbm"), - "quality_percent": system.get("wifi_quality"), - "active_connection": wifi_raw.get("ACTIVE_CONNECTION"), - "ap_ipv4": wifi_raw.get("AP_IPV4"), - "error": wifi_raw.get("error"), - } - health = "healthy" - if network.get("error"): - health = "degraded" - if not camera_status.get("connected", False): - health = "degraded" + network = self._build_network_status(profile, system) + health_reasons = self._health_reasons( + camera_status, + network, + network_in_health_scope=network_in_health_scope, + ) + health = "healthy" if not health_reasons else "degraded" return { "success": True, "health": health, + "health_reasons": health_reasons, "version": __version__, "capabilities": capability_map(), "hardware_plane": { - "started": bool(hw_status_data.get("started", False)) - if isinstance(hw_status_data, dict) - else False, - "metrics": hw_status_data.get("metrics", {}) - if isinstance(hw_status_data, dict) - else {}, - "services": hw_status_data.get("services", {}) - if isinstance(hw_status_data, dict) - else {}, + "started": ( + bool(hw_status_data.get("started", False)) + if isinstance(hw_status_data, dict) + else False + ), + "metrics": ( + hw_status_data.get("metrics", {}) + if isinstance(hw_status_data, dict) + else {} + ), + "services": ( + hw_status_data.get("services", {}) + if isinstance(hw_status_data, dict) + else {} + ), }, "system": system, "camera": {"success": True, **camera_status}, @@ -178,9 +313,15 @@ async def get_camera_status(self) -> dict[str, Any]: status = await camera_domain_service.get_status() normalized = self._normalize_camera_status(status) if hp_camera: - normalized["connected"] = bool(hp_camera.get("connected", normalized["connected"])) - normalized["streaming"] = bool(hp_camera.get("streaming", normalized["streaming"])) - normalized["recording"] = bool(hp_camera.get("recording", normalized["recording"])) + normalized["connected"] = bool( + hp_camera.get("connected", normalized["connected"]) + ) + normalized["streaming"] = bool( + hp_camera.get("streaming", normalized["streaming"]) + ) + normalized["recording"] = bool( + hp_camera.get("recording", normalized["recording"]) + ) return {"success": True, **normalized} async def start_camera(self) -> dict[str, Any]: @@ -222,7 +363,9 @@ async def tune_camera(self, payload: dict[str, Any]) -> dict[str, Any]: applied["auto_exposure"] = bool(auto_exposure) if payload.get("exposure_us") is not None: - await camera_domain_service.update_settings({"exposure": payload["exposure_us"]}) + await camera_domain_service.update_settings( + {"exposure": payload["exposure_us"]} + ) applied["exposure_us"] = int(payload["exposure_us"]) if payload.get("analogue_gain") is not None: @@ -297,7 +440,7 @@ async def tune_camera(self, payload: dict[str, Any]) -> dict[str, Any]: async def get_stream_status(self) -> dict[str, Any]: """获取流控状态 / Get stream limiter status.""" - stream = stream_state_domain_service.get_stream_status() + stream = await stream_state_domain_service.get_stream_status() return { "success": True, **stream, diff --git a/ogscope/core/capabilities/registry.py b/ogscope/core/capabilities/registry.py index f98931c..74fb62c 100644 --- a/ogscope/core/capabilities/registry.py +++ b/ogscope/core/capabilities/registry.py @@ -8,7 +8,9 @@ from dataclasses import dataclass from typing import Any -from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client +from ogscope.config import get_settings +from ogscope.platform.hardware.wifi_switch import wifi_switch_service +from ogscope.platform.hardware_plane.runtime import describe_hardware_plane_profile def _module_available(module_name: str) -> bool: @@ -35,10 +37,16 @@ def to_dict(self) -> dict[str, bool]: def detect_capabilities() -> CapabilitySnapshot: """检测当前运行能力 / Detect runtime capabilities.""" + profile = describe_hardware_plane_profile(get_settings()) + network_managed = ( + not bool(profile.get("subordinate_mode")) + and wifi_switch_service.is_configured() + and _module_available("ogscope.domain.network.services") + ) return CapabilitySnapshot( analysis=_module_available("ogscope.domain.analysis.services"), camera=_module_available("ogscope.platform.hardware.camera"), - network=_module_available("ogscope.domain.network.services"), + network=network_managed, ) @@ -49,6 +57,8 @@ def capability_map() -> dict[str, Any]: async def capability_inventory() -> list[dict[str, Any]]: """返回硬件平面能力清单 / Return hardware-plane capability inventory.""" + from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client + client = get_hardware_plane_client() resp = await client.capability_list() if not resp.get("success"): diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py index 0add1ed..61268b1 100644 --- a/ogscope/core/realtime/service.py +++ b/ogscope/core/realtime/service.py @@ -5,10 +5,12 @@ from __future__ import annotations import asyncio +import time from dataclasses import dataclass from typing import Any from ogscope.algorithms.plate_solve import PlateSolver, SolveResult +from ogscope.algorithms.plate_solve.sensor_context import attach_sensor_prediction from ogscope.algorithms.star_extract import StarExtractor, StarPoint from ogscope.config import effective_solver_max_stars, get_settings from ogscope.web.camera_shared import get_camera_manager @@ -45,6 +47,11 @@ def __init__(self) -> None: self._fov_estimate: float | None = None self._fov_max_error: float | None = None self._solve_timeout_ms: int | None = None + self._solve_context: Any | None = None + self._analysis_interval_sec = max( + float(settings.star_analysis_min_interval_ms) / 1000.0, + 1.0 / max(0.01, float(settings.star_analysis_target_fps)), + ) async def start( self, @@ -53,6 +60,7 @@ async def start( fov_estimate: float | None = None, fov_max_error: float | None = None, solve_timeout_ms: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """启动实时解算 / Start realtime solving""" if self.state.running: @@ -67,6 +75,7 @@ async def start( self._fov_estimate = fov_estimate self._fov_max_error = fov_max_error self._solve_timeout_ms = solve_timeout_ms + self._solve_context = solve_context self.state = RealtimeState(running=True) self._previous_stars = None self._task = asyncio.create_task(self._loop()) @@ -96,8 +105,15 @@ async def get_status(self) -> dict[str, Any]: async def _loop(self) -> None: """后台循环 / Background loop""" + last_started_mono = 0.0 + last_frame_id = -1 while self.state.running: try: + remaining = self._analysis_interval_sec - ( + time.monotonic() - last_started_mono + ) + if remaining > 0: + await asyncio.sleep(remaining) manager = get_camera_manager() cam = manager.get_camera_instance() if not cam or not getattr(cam, "is_capturing", False): @@ -106,13 +122,18 @@ async def _loop(self) -> None: # 必须与共享预览走同一套读锁 + 线程卸载,禁止在事件循环线程里直接 capture_array # Must share the same read lock as shared preview; never call capture_array on the event-loop thread. try: - frame, _fid, _ts = await manager.get_raw_frame() + frame, frame_id, _ts = await manager.get_raw_frame() except RuntimeError: - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) continue if frame is None: - await asyncio.sleep(0.02) + await asyncio.sleep(0.1) + continue + if frame_id == last_frame_id: + await asyncio.sleep(0.05) continue + last_frame_id = frame_id + last_started_mono = time.monotonic() stars = self.extractor.extract(frame) self.state.frame_count += 1 @@ -129,7 +150,6 @@ async def _loop(self) -> None: self._apply_solve_result(solved) self.state.fullsolve_count += 1 self._previous_stars = stars - await asyncio.sleep(0.02) except Exception as exc: # noqa: BLE001 self.state.last_error = str(exc) await asyncio.sleep(0.1) @@ -153,7 +173,9 @@ def _solve_frame_sync( def _apply_solve_result(self, solved: SolveResult) -> None: """写入解算结果 / Persist solve result""" - self.state.last_result = solved.to_dict() + row = solved.to_dict() + attach_sensor_prediction(row, self._solve_context) + self.state.last_result = row self._hint_ra = solved.ra_deg self._hint_dec = solved.dec_deg diff --git a/ogscope/domain/__init__.py b/ogscope/domain/__init__.py index 58bf2dc..dcf5dab 100644 --- a/ogscope/domain/__init__.py +++ b/ogscope/domain/__init__.py @@ -1,4 +1,3 @@ """ 领域层聚合导出 / Domain layer package exports. """ - diff --git a/ogscope/domain/analysis/__init__.py b/ogscope/domain/analysis/__init__.py index 22d9b98..b83e152 100644 --- a/ogscope/domain/analysis/__init__.py +++ b/ogscope/domain/analysis/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.analysis.services import analysis_domain_service __all__ = ["analysis_domain_service"] - diff --git a/ogscope/domain/analysis/services.py b/ogscope/domain/analysis/services.py index 5579329..11ab5eb 100644 --- a/ogscope/domain/analysis/services.py +++ b/ogscope/domain/analysis/services.py @@ -29,7 +29,9 @@ def resolve_upload_file_response(path: Path) -> tuple[Path, str]: return path, media or "application/octet-stream" @staticmethod - def parse_frame_upload_payload(payload: str) -> tuple[dict[str, Any], dict[str, Any]]: + def parse_frame_upload_payload( + payload: str, + ) -> tuple[dict[str, Any], dict[str, Any]]: obj = json.loads(payload) if not isinstance(obj, dict): raise ValueError("payload 必须为 JSON 对象 / payload must be a JSON object") @@ -51,4 +53,3 @@ def parse_frame_upload_payload(payload: str) -> tuple[dict[str, Any], dict[str, analysis_domain_service = AnalysisDomainService() __all__ = ["analysis_domain_service", "AnalysisDomainService"] - diff --git a/ogscope/domain/camera/__init__.py b/ogscope/domain/camera/__init__.py index 0c2d1e4..99dca18 100644 --- a/ogscope/domain/camera/__init__.py +++ b/ogscope/domain/camera/__init__.py @@ -5,4 +5,3 @@ ) __all__ = ["DebugCameraService", "DebugFileService", "DebugPresetService"] - diff --git a/ogscope/domain/camera/driver.py b/ogscope/domain/camera/driver.py new file mode 100644 index 0000000..5eb76f8 --- /dev/null +++ b/ogscope/domain/camera/driver.py @@ -0,0 +1,69 @@ +"""相机驱动抽象与未来 Linuxpy 入口 / Camera driver abstractions and future Linuxpy hook.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + + +@dataclass(slots=True) +class FrameBuffer: + """跨驱动帧载体;data 可为 ndarray、bytes 或 memoryview / Cross-driver frame carrier.""" + + data: Any + width: int + height: int + pixel_format: str = "RGB888" + timestamp: float = 0.0 + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass(slots=True) +class CameraCapabilities: + """驱动能力描述,供 API 与前端安全降级 / Driver capability description for API/UI fallback.""" + + driver: str = "unknown" + backend: str = "unknown" + lores_stream: bool = False + lores_width: int = 0 + lores_height: int = 0 + lores_format: str = "" + awb_modes: tuple[str, ...] = ("auto", "manual", "night") + ae_flicker: bool = False + noise_reduction_modes: tuple[str, ...] = ("off", "fast", "high_quality") + manual_digital_gain: bool = False + autofocus: bool = False + hdr: bool = False + + +class CameraDriver(Protocol): + """最小相机驱动协议 / Minimal camera driver protocol.""" + + is_initialized: bool + is_capturing: bool + + def initialize(self) -> bool: ... + + def start_capture(self) -> bool: ... + + def stop_capture(self) -> bool: ... + + def get_video_frame(self) -> Any: ... + + def get_camera_info(self) -> dict[str, Any]: ... + + +class LinuxpyV4L2Driver: + """Linuxpy/V4L2 预留骨架;本轮不作为树莓派 CSI 默认路径 / Reserved linuxpy/V4L2 stub.""" + + is_initialized = False + is_capturing = False + + def __init__(self, config: dict[str, Any]): + self.config = config + + def initialize(self) -> bool: + """真实 linuxpy 适配将在自定义 Linux 系统中实现 / Real linuxpy adapter is implemented later.""" + raise NotImplementedError( + "linuxpy driver is reserved but not implemented / linuxpy 驱动已预留但未实现" + ) diff --git a/ogscope/domain/camera/encoding.py b/ogscope/domain/camera/encoding.py new file mode 100644 index 0000000..579a651 --- /dev/null +++ b/ogscope/domain/camera/encoding.py @@ -0,0 +1,206 @@ +"""预览图像编码器 / Preview image encoders.""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Any, Protocol + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class EncodedImage: + """编码结果 / Encoded image result.""" + + data: bytes + encoder: str + source_format: str + + +class PreviewEncoder(Protocol): + """预览编码器协议 / Preview encoder protocol.""" + + name: str + + def encode_jpeg( + self, frame: Any, *, quality: int = 75, source_format: str = "RGB888" + ) -> EncodedImage | None: ... + + def encode_png( + self, frame: Any, *, source_format: str = "RGB888" + ) -> bytes | None: ... + + +class OpenCVEncoder: + """OpenCV 后备编码器 / OpenCV fallback encoder.""" + + name = "opencv" + + @staticmethod + def _to_cv_bgr(frame: Any, cv2_module: Any, source_format: str) -> Any: + """按源格式转换给 OpenCV;相机默认 RGB888 / Convert source frame for OpenCV.""" + fmt = str(source_format or "RGB888").upper() + try: + if getattr(frame, "ndim", 0) == 3 and int(frame.shape[2]) >= 3: + if fmt in {"RGB888", "RGB", "RGB24"}: + return cv2_module.cvtColor(frame, cv2_module.COLOR_RGB2BGR) + if fmt in {"BGR888", "BGR", "BGR24"}: + return frame + except Exception: + return frame + return frame + + def encode_jpeg( + self, frame: Any, *, quality: int = 75, source_format: str = "RGB888" + ) -> EncodedImage | None: + """编码 JPEG / Encode JPEG.""" + try: + import cv2 + + frame_for_cv = self._to_cv_bgr(frame, cv2, source_format) + ok, buf = cv2.imencode( + ".jpg", + frame_for_cv, + [cv2.IMWRITE_JPEG_QUALITY, int(max(10, min(100, quality)))], + ) + if not ok: + return None + return EncodedImage(buf.tobytes(), self.name, source_format) + except Exception as exc: + logger.debug("OpenCV JPEG encode failed / OpenCV JPEG 编码失败: %s", exc) + return None + + def encode_png(self, frame: Any, *, source_format: str = "RGB888") -> bytes | None: + """编码 PNG / Encode PNG.""" + try: + import cv2 + + frame_for_cv = self._to_cv_bgr(frame, cv2, source_format) + ok, buf = cv2.imencode(".png", frame_for_cv) + if not ok: + return None + return buf.tobytes() + except Exception as exc: + logger.debug("OpenCV PNG encode failed / OpenCV PNG 编码失败: %s", exc) + return None + + +class TurboJPEGEncoder: + """可选 TurboJPEG 编码器 / Optional TurboJPEG encoder.""" + + name = "turbojpeg" + + def __init__(self) -> None: + from turbojpeg import TJPF_BGR, TJPF_RGB, TurboJPEG + + self._jpeg = TurboJPEG() + self._tjpf_rgb = TJPF_RGB + self._tjpf_bgr = TJPF_BGR + + @classmethod + def available(cls) -> bool: + """探测 TurboJPEG 是否可用 / Check whether TurboJPEG is importable and loadable.""" + try: + cls() + return True + except Exception: + return False + + def encode_jpeg( + self, frame: Any, *, quality: int = 75, source_format: str = "RGB888" + ) -> EncodedImage | None: + """编码 JPEG;TurboJPEG 支持直接输入 RGB/BGR / Encode JPEG from RGB/BGR.""" + fmt = str(source_format or "RGB888").upper() + pixel_format = ( + self._tjpf_bgr if fmt in {"BGR888", "BGR", "BGR24"} else self._tjpf_rgb + ) + try: + data = self._jpeg.encode( + frame, + quality=int(max(10, min(100, quality))), + pixel_format=pixel_format, + ) + return EncodedImage(bytes(data), self.name, source_format) + except Exception as exc: + logger.debug("TurboJPEG encode failed / TurboJPEG 编码失败: %s", exc) + return None + + def encode_png(self, frame: Any, *, source_format: str = "RGB888") -> bytes | None: + """TurboJPEG 不负责 PNG,交给 OpenCV / TurboJPEG does not encode PNG; delegate elsewhere.""" + return OpenCVEncoder().encode_png(frame, source_format=source_format) + + +class AutoPreviewEncoder: + """按真实首帧测速选择编码器 / Select encoder by benchmarking the first real frame.""" + + def __init__(self) -> None: + self._opencv = OpenCVEncoder() + try: + self._turbo: PreviewEncoder | None = TurboJPEGEncoder() + except Exception: + self._turbo = None + self._selected: PreviewEncoder | None = None + + @property + def name(self) -> str: + """当前选择的编码器名称 / Current selected encoder name.""" + if self._selected is not None: + return self._selected.name + return "auto" + + def encode_jpeg( + self, frame: Any, *, quality: int = 75, source_format: str = "RGB888" + ) -> EncodedImage | None: + """首帧同时试跑候选编码器,后续固定使用更快者 / Benchmark candidates once, then reuse winner.""" + if self._selected is not None: + return self._selected.encode_jpeg( + frame, quality=quality, source_format=source_format + ) + if self._turbo is None: + self._selected = self._opencv + return self._opencv.encode_jpeg( + frame, quality=quality, source_format=source_format + ) + + results: list[tuple[float, PreviewEncoder, EncodedImage]] = [] + for encoder in (self._opencv, self._turbo): + t0 = time.perf_counter() + encoded = encoder.encode_jpeg( + frame, quality=quality, source_format=source_format + ) + if encoded is not None: + results.append(((time.perf_counter() - t0) * 1000.0, encoder, encoded)) + if not results: + return None + results.sort(key=lambda item: item[0]) + self._selected = results[0][1] + logger.info( + "Auto preview encoder selected %s (%.1f ms) / 自动预览编码器选择 %s (%.1f ms)", + self._selected.name, + results[0][0], + self._selected.name, + results[0][0], + ) + return results[0][2] + + def encode_png(self, frame: Any, *, source_format: str = "RGB888") -> bytes | None: + """PNG 仍使用 OpenCV / PNG still uses OpenCV.""" + return self._opencv.encode_png(frame, source_format=source_format) + + +def create_preview_encoder(preference: str = "auto") -> PreviewEncoder: + """创建预览编码器,auto 优先 TurboJPEG / Create preview encoder; auto prefers TurboJPEG.""" + pref = str(preference or "auto").lower() + if pref == "auto": + return AutoPreviewEncoder() + if pref == "turbojpeg": + try: + return TurboJPEGEncoder() + except Exception as exc: + logger.warning( + "TurboJPEG unavailable, fallback to OpenCV / TurboJPEG 不可用,回退 OpenCV: %s", + exc, + ) + return OpenCVEncoder() diff --git a/ogscope/domain/camera/services.py b/ogscope/domain/camera/services.py index 2c0e29a..5331904 100644 --- a/ogscope/domain/camera/services.py +++ b/ogscope/domain/camera/services.py @@ -8,13 +8,12 @@ import time from typing import Any -from fastapi import HTTPException from fastapi.responses import Response from starlette.requests import Request -from ogscope.platform.adapters.debug_services import get_debug_services_module from ogscope.config import get_settings from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter +from ogscope.platform.adapters.debug_services import get_debug_services_module logger = logging.getLogger(__name__) @@ -137,14 +136,18 @@ async def get_file_info(self, filename: str) -> dict[str, Any]: class StreamStateDomainService: """流状态门面 / Stream state facade.""" - def get_stream_status(self) -> dict[str, int]: + async def get_stream_status(self) -> dict[str, Any]: limiter = get_mjpeg_stream_limiter() settings = get_settings() + from ogscope.web.camera_shared import get_camera_manager + + metrics = await get_camera_manager().stream_metrics() return { "max_clients": int(limiter.max_clients), "active_clients": int(limiter.active_clients), "frame_fetch_timeout_ms": int(settings.stream_mjpeg_frame_fetch_timeout_ms), - "target_preview_fps": int(settings.shared_preview_fps), + "target_preview_fps": int(metrics["preview_target_fps"]), + **metrics, } @@ -173,11 +176,15 @@ async def get_runtime_overrides(): @staticmethod async def clear_runtime_overrides(): - return await _debug_services_module().DebugCameraService.clear_runtime_overrides() + return ( + await _debug_services_module().DebugCameraService.clear_runtime_overrides() + ) @staticmethod async def apply_runtime_overrides_as_defaults(): - return await _debug_services_module().DebugCameraService.apply_runtime_overrides_as_defaults() + return ( + await _debug_services_module().DebugCameraService.apply_runtime_overrides_as_defaults() + ) @staticmethod async def start_camera(): @@ -229,7 +236,9 @@ async def set_fps(fps: int): @staticmethod async def update_settings(settings: dict[str, Any]): - return await _debug_services_module().DebugCameraService.update_settings(settings) + return await _debug_services_module().DebugCameraService.update_settings( + settings + ) @staticmethod async def set_auto_exposure_mode(enabled: bool): @@ -251,19 +260,27 @@ async def get_image_quality(): @staticmethod async def apply_night_mode_preset(): - return await _debug_services_module().DebugCameraService.apply_night_mode_preset() + return ( + await _debug_services_module().DebugCameraService.apply_night_mode_preset() + ) @staticmethod async def save_current_settings_backup(): - return await _debug_services_module().DebugCameraService.save_current_settings_backup() + return ( + await _debug_services_module().DebugCameraService.save_current_settings_backup() + ) @staticmethod async def restore_settings_backup(): - return await _debug_services_module().DebugCameraService.restore_settings_backup() + return ( + await _debug_services_module().DebugCameraService.restore_settings_backup() + ) @staticmethod async def set_color_mode(color_mode: str): - return await _debug_services_module().DebugCameraService.set_color_mode(color_mode) + return await _debug_services_module().DebugCameraService.set_color_mode( + color_mode + ) @staticmethod async def set_white_balance(mode: str, gain_r: float, gain_b: float): @@ -307,11 +324,16 @@ async def save_preset(payload: dict[str, Any]): @staticmethod async def apply_preset(preset_name: str): - return await _debug_services_module().DebugPresetService.apply_preset(preset_name) + return await _debug_services_module().DebugPresetService.apply_preset( + preset_name + ) @staticmethod async def delete_preset(preset_name: str): - return await _debug_services_module().DebugPresetService.delete_preset(preset_name) + return await _debug_services_module().DebugPresetService.delete_preset( + preset_name + ) + __all__ = [ "DebugCameraService", @@ -323,4 +345,3 @@ async def delete_preset(preset_name: str): "file_domain_service", "stream_state_domain_service", ] - diff --git a/ogscope/domain/camera/sidecar.py b/ogscope/domain/camera/sidecar.py index 4462386..fadd41c 100644 --- a/ogscope/domain/camera/sidecar.py +++ b/ogscope/domain/camera/sidecar.py @@ -41,4 +41,3 @@ def merge_capture_sidecar_into_info( if key not in capture_info: capture_info[key] = value info.update(capture_info) - diff --git a/ogscope/domain/camera/stream_limiter.py b/ogscope/domain/camera/stream_limiter.py index 740cab4..a952f48 100644 --- a/ogscope/domain/camera/stream_limiter.py +++ b/ogscope/domain/camera/stream_limiter.py @@ -52,4 +52,3 @@ def get_mjpeg_stream_limiter() -> MjpegStreamLimiter: if _limiter is None: _limiter = MjpegStreamLimiter(get_settings().stream_max_mjpeg_clients) return _limiter - diff --git a/ogscope/domain/camera/streaming.py b/ogscope/domain/camera/streaming.py index a2462f3..dfe5df4 100644 --- a/ogscope/domain/camera/streaming.py +++ b/ogscope/domain/camera/streaming.py @@ -15,6 +15,7 @@ from ogscope.config import get_settings from ogscope.domain.camera.services import camera_domain_service from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter +from ogscope.web.camera_shared import get_camera_manager from ogscope.web.mjpeg_stream_helpers import mjpeg_sleep_or_disconnect @@ -40,12 +41,13 @@ async def build_camera_mjpeg_stream( raise HTTPException(status_code=503, detail=limit_detail) boundary = "frame" settings = get_settings() - min_emit_interval = 1.0 / max(1, int(settings.shared_preview_fps)) fetch_timeout_s = settings.stream_mjpeg_frame_fetch_timeout_ms / 1000.0 content_type = "image/jpeg" if image_format.lower() == "jpeg" else "image/png" async def frame_generator(): + manager = get_camera_manager() try: + await manager.acquire_preview_consumer() last_snap_frame_id = -1 last_emit_mono = 0.0 while True: @@ -70,6 +72,7 @@ async def frame_generator(): break continue now = time.monotonic() + min_emit_interval = 1.0 / max(1, manager.preview_target_fps) wait = last_emit_mono + min_emit_interval - now if wait > 0 and not await mjpeg_sleep_or_disconnect(request, wait): break @@ -89,10 +92,10 @@ async def frame_generator(): + b"\r\n" ) finally: + await manager.release_preview_consumer() await limiter.release() return StreamingResponse( frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}", ) - diff --git a/ogscope/domain/network/__init__.py b/ogscope/domain/network/__init__.py index a3d1b76..87bcde1 100644 --- a/ogscope/domain/network/__init__.py +++ b/ogscope/domain/network/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.network.services import wifi_domain_service __all__ = ["wifi_domain_service"] - diff --git a/ogscope/domain/network/nmcli_services.py b/ogscope/domain/network/nmcli_services.py index f9d54bb..c1f1ace 100644 --- a/ogscope/domain/network/nmcli_services.py +++ b/ogscope/domain/network/nmcli_services.py @@ -403,4 +403,3 @@ async def _sta_rollback_loop() -> None: raise except Exception as e: logger.error("STA 回滚失败 / Rollback to AP failed: {}", e) - diff --git a/ogscope/domain/network/services.py b/ogscope/domain/network/services.py index d05e84d..1218ab9 100644 --- a/ogscope/domain/network/services.py +++ b/ogscope/domain/network/services.py @@ -8,8 +8,8 @@ import subprocess from ogscope.config import get_settings -from ogscope.platform.hardware.wifi_switch import wifi_switch_service from ogscope.domain.network import nmcli_services as net_impl +from ogscope.platform.hardware.wifi_switch import wifi_switch_service class WifiDomainService: @@ -27,7 +27,9 @@ def build_wifi_status() -> dict: ap_connection = data.get("AP_CONNECTION", settings.wifi_ap_connection) ap_ipv4 = data.get("AP_IPV4") or None ap_url_hint = ( - f"http://{settings.wifi_ap_url_host}:{settings.port}" if mode == "ap" else None + f"http://{settings.wifi_ap_url_host}:{settings.port}" + if mode == "ap" + else None ) message = data.get("error") suffix = settings.device_id_suffix or None @@ -58,7 +60,9 @@ async def switch_mode(self, mode: str) -> dict: async def scan_wifi(self): settings = get_settings() - return await asyncio.to_thread(net_impl.nmcli_wifi_scan, settings.wifi_interface) + return await asyncio.to_thread( + net_impl.nmcli_wifi_scan, settings.wifi_interface + ) async def list_profiles(self): settings = get_settings() @@ -68,7 +72,9 @@ async def connect_sta(self, ssid: str, password: str) -> dict: settings = get_settings() if not wifi_switch_service.is_configured(): raise RuntimeError("wifi_not_configured") - await asyncio.to_thread(net_impl.nmcli_modify_sta_to_ssid, settings, ssid, password) + await asyncio.to_thread( + net_impl.nmcli_modify_sta_to_ssid, settings, ssid, password + ) await asyncio.to_thread(wifi_switch_service.switch, "sta") net_impl.schedule_sta_rollback_watch() return self.build_wifi_status() @@ -83,7 +89,9 @@ async def activate_profile(self, connection_name: str) -> dict: if name == settings.wifi_sta_connection: await asyncio.to_thread(wifi_switch_service.switch, "sta") else: - await asyncio.to_thread(net_impl.nm_down_if_exists, settings.wifi_ap_connection) + await asyncio.to_thread( + net_impl.nm_down_if_exists, settings.wifi_ap_connection + ) await asyncio.to_thread(net_impl.nmcli_activate_connection, settings, name) net_impl.schedule_sta_rollback_watch() return self.build_wifi_status() @@ -115,4 +123,3 @@ async def activate_profile(self, connection_name: str) -> dict: "TimeoutExpired", "CalledProcessError", ] - diff --git a/ogscope/domain/shared/filesystem.py b/ogscope/domain/shared/filesystem.py index c5fff0b..2d8aa5c 100644 --- a/ogscope/domain/shared/filesystem.py +++ b/ogscope/domain/shared/filesystem.py @@ -6,7 +6,7 @@ from pathlib import Path, PurePath -DEV_CAPTURES_DIR = Path.home() / "dev_captures" +DEV_CAPTURES_DIR = Path("/tmp/dev_captures") DEV_CAPTURES_DIR.mkdir(exist_ok=True) IMAGE_EXTENSIONS = { @@ -39,4 +39,3 @@ def ensure_safe_basename(filename: str) -> str: if "/" in safe_name or "\\" in safe_name: raise ValueError("invalid filename") return safe_name - diff --git a/ogscope/domain/system/__init__.py b/ogscope/domain/system/__init__.py index 3925486..77c8228 100644 --- a/ogscope/domain/system/__init__.py +++ b/ogscope/domain/system/__init__.py @@ -1,4 +1,3 @@ from ogscope.domain.system.services import system_info_service __all__ = ["system_info_service"] - diff --git a/ogscope/domain/system/services.py b/ogscope/domain/system/services.py index 7e5cad1..de9a238 100644 --- a/ogscope/domain/system/services.py +++ b/ogscope/domain/system/services.py @@ -225,7 +225,9 @@ def read_systemd_logs( rt = item.get("__REALTIME_TIMESTAMP") try: if rt is not None: - ts = dt.datetime.fromtimestamp(int(str(rt)) / 1_000_000, tz=dt.timezone.utc) + ts = dt.datetime.fromtimestamp( + int(str(rt)) / 1_000_000, tz=dt.timezone.utc + ) ts_iso = ts.isoformat() except (ValueError, TypeError): ts_iso = None @@ -256,4 +258,3 @@ def _journal_priority_to_level(priority: str | int | None) -> str: system_info_service = SystemInfoService() __all__ = ["SystemInfoService", "system_info_service", "read_systemd_logs"] - diff --git a/ogscope/platform/adapters/debug_services.py b/ogscope/platform/adapters/debug_services.py index 10fa692..5b37dd6 100644 --- a/ogscope/platform/adapters/debug_services.py +++ b/ogscope/platform/adapters/debug_services.py @@ -10,4 +10,3 @@ def get_debug_services_module(): """延迟加载调试实现模块 / Lazy load debug implementation module.""" return importlib.import_module("ogscope.web.api.debug.services") - diff --git a/ogscope/platform/hardware/ak09911_i2c.py b/ogscope/platform/hardware/ak09911_i2c.py index 040196d..27fbfb7 100644 --- a/ogscope/platform/hardware/ak09911_i2c.py +++ b/ogscope/platform/hardware/ak09911_i2c.py @@ -204,7 +204,9 @@ def _measure_body_smbus(smbus: Any, addr7: int) -> Ak09911Measurement: time.sleep(0.006 * (read_try + 1)) if data is None: if last_io is not None: - raise RuntimeError(_err_ctx("hxl_read", last_io, addr7).to_text()) from last_io + raise RuntimeError( + _err_ctx("hxl_read", last_io, addr7).to_text() + ) from last_io raise RuntimeError("hxl_read unknown error") try: _ = smbus.read_byte_data(addr7, REG_ST2) @@ -215,7 +217,9 @@ def _measure_body_smbus(smbus: Any, addr7: int) -> Ak09911Measurement: try: _ = smbus.read_byte_data(addr7, REG_ST2) except OSError as exc2: - raise RuntimeError(_err_ctx("st2_read", exc2, addr7).to_text()) from exc2 + raise RuntimeError( + _err_ctx("st2_read", exc2, addr7).to_text() + ) from exc2 else: raise RuntimeError(_err_ctx("st2_read", exc, addr7).to_text()) from exc hx, hy, hz = _combine_hxl_6(data) @@ -303,7 +307,9 @@ def measure_heading_with_cad_fallback( ) -def measure_single(bus: int, addr7: int) -> tuple[Ak09911Measurement | None, str | None]: +def measure_single( + bus: int, addr7: int +) -> tuple[Ak09911Measurement | None, str | None]: path = ensure_i2c_dev_node(bus) if path is None: return None, f"missing {i2c_dev_path(bus)}" diff --git a/ogscope/platform/hardware/camera.py b/ogscope/platform/hardware/camera.py index 737662e..076889d 100644 --- a/ogscope/platform/hardware/camera.py +++ b/ogscope/platform/hardware/camera.py @@ -10,6 +10,8 @@ import numpy as np +from ogscope.domain.camera.driver import CameraCapabilities, LinuxpyV4L2Driver + logger = logging.getLogger(__name__) @@ -69,6 +71,13 @@ def __init__(self, config: dict[str, Any]): self.camera = None self.is_initialized = False self.is_capturing = False + self._last_metadata: dict[str, Any] = {} + self.driver_name = "picamera2-imx327" + self.backend_name = "picamera2/libcamera" + self.output_pixel_format = "RGB888" + self._frame_duration_limits: tuple[int, int] | None = None + self._lores_available = False + self._last_lores_stats: dict[str, Any] = {} # 相机参数 / Camera parameters requested_width = int(config.get("width", 640)) @@ -90,6 +99,16 @@ def __init__(self, config: dict[str, Any]): self.white_balance_mode = config.get("white_balance_mode", "auto") self.white_balance_gain_r = config.get("white_balance_gain_r", 1.0) self.white_balance_gain_b = config.get("white_balance_gain_b", 1.0) + self.night_mode = bool(config.get("night_mode", False)) + self.auto_exposure_max_us = int(config.get("auto_exposure_max_us", 2_000_000)) + self.ae_flicker_mode = str(config.get("ae_flicker_mode", "off")).lower() + self.noise_reduction_mode = self._normalize_noise_reduction_mode( + config.get("noise_reduction_mode", config.get("noise_reduction", "fast")) + ) + self.lores_enabled = bool(config.get("lores_enabled", True)) + self.lores_width = self._align_even(int(config.get("lores_width", 320))) + self.lores_height = self._align_even(int(config.get("lores_height", 240))) + self.lores_format = str(config.get("lores_format", "YUV420")) # 采样模式与尺寸(supersample: 采集分辨率可高于输出分辨率) / Sampling mode and size (supersample: acquisition resolution can be higher than output resolution) self.sampling_mode = config.get( "sampling_mode", "native" @@ -310,12 +329,222 @@ def _resize_preserve_fov( value=border_value, ) + def _camera_controls(self) -> dict[str, Any]: + """读取 libcamera 控制表;测试桩缺失时返回空表 / Read libcamera controls; empty for test doubles.""" + return getattr(self.camera, "camera_controls", None) or {} + + def _control_supported(self, name: str) -> bool: + """控制项能力检查 / Check whether a camera control is supported.""" + return name in self._camera_controls() + + @staticmethod + def _normalize_noise_reduction_mode(value: Any) -> str: + """归一化降噪语义模式 / Normalize semantic noise-reduction mode.""" + if isinstance(value, int): + return "off" if value <= 0 else "fast" if value <= 2 else "high_quality" + text = str(value or "fast").strip().lower().replace("-", "_") + aliases = { + "0": "off", + "1": "fast", + "2": "fast", + "3": "high_quality", + "4": "high_quality", + "hq": "high_quality", + } + text = aliases.get(text, text) + return text if text in {"off", "fast", "high_quality"} else "fast" + + @staticmethod + def _enum_value(enum_obj: Any, *names: str) -> Any | None: + """安全读取 libcamera 枚举值 / Safely read a libcamera enum value.""" + for name in names: + if hasattr(enum_obj, name): + return getattr(enum_obj, name) + return None + + def _target_frame_period_us(self) -> int: + """目标帧周期 us / Target frame period in microseconds.""" + return max(1, int(round(1_000_000.0 / max(1.0, float(self.fps))))) + + def _compute_frame_duration_limits(self) -> tuple[int, int]: + """生成帧周期限制;自动曝光允许长曝光降帧 / Build frame-duration limits.""" + target_us = self._target_frame_period_us() + if self.auto_exposure: + max_us = max(target_us, int(self.auto_exposure_max_us)) + return target_us, max_us + fixed_us = max(target_us, int(self.exposure_us)) + return fixed_us, fixed_us + + def _apply_frame_duration_controls(self) -> None: + """优先应用 FrameDurationLimits,失败时回退 FrameRate / Prefer FrameDurationLimits, fallback to FrameRate.""" + if not self.camera: + return + limits = self._compute_frame_duration_limits() + self._frame_duration_limits = limits + if self._control_supported("FrameDurationLimits"): + try: + self.camera.set_controls({"FrameDurationLimits": limits}) + return + except Exception as e: + logger.debug("FrameDurationLimits 未生效,回退 FrameRate: %s", e) + try: + self.camera.set_controls({"FrameRate": float(self.fps)}) + except Exception: + pass + + def _noise_reduction_control_value(self) -> Any: + """将语义模式映射到 libcamera 降噪枚举/整数 / Map semantic NR mode to libcamera value.""" + try: + from picamera2 import controls as pcc + + enum_obj = getattr(pcc, "draft", pcc) + enum_obj = getattr(enum_obj, "NoiseReductionModeEnum", enum_obj) + if self.noise_reduction_mode == "off": + val = self._enum_value(enum_obj, "Off") + return 0 if val is None else val + if self.noise_reduction_mode == "high_quality": + val = self._enum_value(enum_obj, "HighQuality", "HighQualityMode") + return 2 if val is None else val + val = self._enum_value(enum_obj, "Fast", "Minimal") + return 1 if val is None else val + except Exception: + return {"off": 0, "fast": 1, "high_quality": 2}[self.noise_reduction_mode] + + def _apply_noise_reduction_controls(self) -> None: + """应用降噪模式;不支持时安全跳过 / Apply NR mode; safely skip unsupported controls.""" + if not self.camera or not self._control_supported("NoiseReductionMode"): + return + try: + self.camera.set_controls( + {"NoiseReductionMode": self._noise_reduction_control_value()} + ) + except Exception as e: + logger.debug("降噪控制未生效 / Noise reduction control skipped: %s", e) + + def _apply_ae_flicker_controls(self) -> None: + """应用 AE 防闪烁;不支持时安全跳过 / Apply AE flicker controls when supported.""" + if not self.camera: + return + mode = str(self.ae_flicker_mode or "off").lower() + updates: dict[str, Any] = {} + if self._control_supported("AeFlickerMode"): + try: + from picamera2 import controls as pcc + + enum_obj = getattr(pcc, "AeFlickerModeEnum", pcc) + enum_value = ( + self._enum_value(enum_obj, "Manual", "FlickerManual") + if mode in {"50hz", "60hz"} + else self._enum_value(enum_obj, "Off", "FlickerOff") + ) + except Exception: + enum_value = None + # Picamera2/libcamera 版本间枚举名有差异;找不到枚举时用整数 fallback,禁止传 None。 + # Enum names differ across Picamera2/libcamera versions; fall back to ints and never pass None. + updates["AeFlickerMode"] = ( + enum_value + if enum_value is not None + else (1 if mode in {"50hz", "60hz"} else 0) + ) + if mode in {"50hz", "60hz"} and self._control_supported("AeFlickerPeriod"): + updates["AeFlickerPeriod"] = 10_000 if mode == "50hz" else 8_333 + if updates: + try: + self.camera.set_controls(updates) + except Exception as e: + logger.debug("AE 防闪烁控制未生效 / AE flicker control skipped: %s", e) + + def _create_video_configuration(self) -> Any: + """创建含可选 lores 的视频配置 / Create video config with optional lores stream.""" + if not self.camera: + raise RuntimeError("camera missing") + main = {"size": (self.capture_width, self.capture_height), "format": "RGB888"} + if self.lores_enabled: + try: + cfg = self.camera.create_video_configuration( + main=main, + lores={ + "size": (self.lores_width, self.lores_height), + "format": self.lores_format, + }, + buffer_count=self.PREVIEW_BUFFER_COUNT, + ) + self._lores_available = True + return cfg + except Exception as e: + self._lores_available = False + logger.debug( + "lores 流不可用,回退主流配置 / Lores unavailable, fallback: %s", e + ) + self._lores_available = False + return self.camera.create_video_configuration( + main=main, + buffer_count=self.PREVIEW_BUFFER_COUNT, + ) + + def _collect_lores_stats(self, request: Any) -> None: + """从 lores 流提取轻量亮度统计 / Extract lightweight luminance stats from lores stream.""" + if not self._lores_available: + return + try: + lores = request.make_array("lores") + if lores is None: + return + if ( + len(getattr(lores, "shape", ())) == 2 + and lores.shape[0] >= self.lores_height + ): + y_plane = lores[: self.lores_height, :] + elif len(getattr(lores, "shape", ())) >= 3: + y_plane = lores[..., 0] + else: + y_plane = lores + self._last_lores_stats = { + "mean": float(np.mean(y_plane)), + "min": int(np.min(y_plane)), + "max": int(np.max(y_plane)), + } + except Exception as e: + logger.debug("读取 lores 统计失败 / Failed to read lores stats: %s", e) + + def _camera_capabilities(self) -> dict[str, Any]: + """汇总相机能力供 API/UI 降级 / Summarize camera capabilities for API/UI fallback.""" + cc = self._camera_controls() + caps = CameraCapabilities( + driver=self.driver_name, + backend=self.backend_name, + lores_stream=bool(self._lores_available), + lores_width=self.lores_width if self._lores_available else 0, + lores_height=self.lores_height if self._lores_available else 0, + lores_format=self.lores_format if self._lores_available else "", + ae_flicker=("AeFlickerMode" in cc or "AeFlickerPeriod" in cc), + manual_digital_gain="DigitalGain" in cc, + autofocus=any(k.startswith("Af") for k in cc), + hdr=any("Hdr" in k or "HDR" in k for k in cc), + ) + return { + "driver": caps.driver, + "backend": caps.backend, + "lores_stream": caps.lores_stream, + "lores_width": caps.lores_width, + "lores_height": caps.lores_height, + "lores_format": caps.lores_format, + "awb_modes": list(caps.awb_modes), + "ae_flicker": caps.ae_flicker, + "noise_reduction_modes": list(caps.noise_reduction_modes), + "manual_digital_gain": caps.manual_digital_gain, + "autofocus": caps.autofocus, + "hdr": caps.hdr, + } + def _apply_polar_auto_exposure_controls(self) -> None: """libcamera AE 预设:暗部优先、矩阵测光、偏长曝光、EV;失败项跳过 / AE preset; skip unsupported controls.""" if not self.camera or not self.auto_exposure: return if not self.ae_polar_preset: try: + self._apply_frame_duration_controls() + self._apply_ae_flicker_controls() self.camera.set_controls({"AeEnable": True}) except Exception as e: logger.debug("AeEnable only: %s", e) @@ -341,6 +570,8 @@ def _apply_polar_auto_exposure_controls(self) -> None: updates["Brightness"] = max(-1.0, min(1.0, ev * 0.2)) try: + self._apply_frame_duration_controls() + self._apply_ae_flicker_controls() self.camera.set_controls(updates) logger.info( "已应用电子极轴镜 AE 预设 (Shadows/Matrix/Long, EV≈%.2f)", @@ -356,6 +587,51 @@ def _apply_polar_auto_exposure_controls(self) -> None: except Exception as err: logger.debug("AE 控制 %s 未生效: %s", key, err) + def _white_balance_controls(self) -> dict[str, Any]: + """生成白平衡控制;auto 必须真正打开 AWB / Build WB controls; auto must really enable AWB.""" + mode = str(self.white_balance_mode or "auto").lower() + if mode == "manual": + return { + "AwbEnable": False, + "ColourGains": ( + float(self.white_balance_gain_r), + float(self.white_balance_gain_b), + ), + } + if mode == "night": + self.white_balance_gain_r = 1.1 + self.white_balance_gain_b = 0.9 + return {"AwbEnable": False, "ColourGains": (1.1, 0.9)} + updates: dict[str, Any] = {"AwbEnable": True} + mode_aliases = { + "auto": "Auto", + "daylight": "Daylight", + "cloudy": "Cloudy", + "tungsten": "Tungsten", + "fluorescent": "Fluorescent", + "indoor": "Indoor", + } + if mode not in mode_aliases: + mode = "auto" + self.white_balance_mode = mode + if self._control_supported("AwbMode"): + try: + from picamera2 import controls as pcc + + enum_obj = getattr(pcc, "AwbModeEnum", pcc) + enum_value = self._enum_value(enum_obj, mode_aliases[mode]) + if enum_value is not None: + updates["AwbMode"] = enum_value + except Exception: + pass + return updates + + def _apply_white_balance_controls(self) -> None: + """重放白平衡控制,避免配置重建后回到错误状态 / Replay WB controls after reconfiguration.""" + if not self.camera: + return + self.camera.set_controls(self._white_balance_controls()) + def initialize(self) -> bool: """初始化 MIPI 相机 / Initialize MIPI camera""" try: @@ -363,18 +639,9 @@ def initialize(self) -> bool: self.camera = Picamera2() - # 统一使用RGB888格式,颜色模式转换在图像处理阶段进行 / RGB888 format is uniformly used, and color mode conversion is performed in the image processing stage. - # 这样可以保持相机配置的一致性,避免格式兼容性问题 / This maintains consistency in camera configuration and avoids format compatibility issues - main_format = "RGB888" - - # 配置相机 / Configure camera - camera_config = self.camera.create_video_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": main_format, - }, - buffer_count=self.PREVIEW_BUFFER_COUNT, - ) + # 配置主流 + 可选 lores 流;RGB888 保证预览/解算色序一致 + # Configure main + optional lores stream; RGB888 keeps preview/solve color order stable. + camera_config = self._create_video_configuration() self.camera.configure(camera_config) @@ -384,9 +651,8 @@ def initialize(self) -> bool: "ExposureTime": self.exposure_us, "AnalogueGain": self.analogue_gain, "AeEnable": self.auto_exposure, - "AwbEnable": False, # 禁用自动白平衡 / Disable automatic white balance - "NoiseReductionMode": 0, # 禁用降噪以获得原始数据 / Disable noise reduction to get raw data } + controls.update(self._white_balance_controls()) try: self.camera.set_controls({**controls, "DigitalGain": self.digital_gain}) except Exception: @@ -395,6 +661,10 @@ def initialize(self) -> bool: if self.auto_exposure: self._apply_polar_auto_exposure_controls() + else: + self._apply_frame_duration_controls() + self._apply_noise_reduction_controls() + self._apply_ae_flicker_controls() self.is_initialized = True logger.info("IMX327 MIPI 相机初始化成功") @@ -416,23 +686,8 @@ def start_capture(self) -> bool: return False try: - # 使用视频配置以获得更高实时性 / Use video configuration for greater real-time performance - try: - video_config = self.camera.create_video_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": "RGB888", - }, - buffer_count=self.PREVIEW_BUFFER_COUNT, - ) - self.camera.configure(video_config) - except Exception as e: - logger.warning(f"视频配置失败,回退到当前配置: {e}") - # 设置目标帧率(若固件支持) / Set target frame rate (if supported by firmware) - try: - self.camera.set_controls({"FrameRate": self.fps}) - except Exception: - pass + # 设置帧周期(优先 FrameDurationLimits) / Set frame period (prefer FrameDurationLimits). + self._apply_frame_duration_controls() # 重新配置后重放曝光控制,避免状态漂移到驱动默认值 / Replay exposure control after reconfiguration to avoid state drift to driver defaults try: @@ -450,6 +705,9 @@ def start_capture(self) -> bool: ) except Exception: self.camera.set_controls(controls) + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_ae_flicker_controls() except Exception as e: logger.warning(f"重放曝光控制失败,使用驱动默认控制: {e}") @@ -486,8 +744,15 @@ def capture_image(self) -> Optional[np.ndarray]: return None try: - # 捕获图像 / capture image - image = self.camera.capture_array() + # 同一请求读取图像与元数据,避免额外等待下一帧 + # Read image and metadata from one request to avoid waiting for another frame. + request = self.camera.capture_request() + try: + image = request.make_array("main") + self._last_metadata = dict(request.get_metadata() or {}) + self._collect_lores_stats(request) + finally: + request.release() # 如果是 RAW 格式,需要转换为 RGB / If it is RAW format, it needs to be converted to RGB if len(image.shape) == 2: # RAW 格式 / RAW format @@ -529,6 +794,11 @@ def capture_image(self) -> Optional[np.ndarray]: image = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) logger.debug("应用黑白模式转换") + if isinstance(image, np.ndarray) and not image.flags["C_CONTIGUOUS"]: + # 旋转/镜像可能产生负 stride 视图,编码器会被迫慢速复制;这里统一整理为连续内存。 + # Rotation/flip may create negative-stride views; make contiguous before encoding/analysis. + image = np.ascontiguousarray(image) + return image except Exception as e: @@ -596,7 +866,7 @@ def set_resolution( and self.sampling_mode == effective_mode ): try: - self.camera.set_controls({"FrameRate": self.fps}) + self._apply_frame_duration_controls() except Exception: pass return True @@ -621,13 +891,7 @@ def set_resolution( if was_capturing and not self.stop_capture(): return False try: - video_config = self.camera.create_video_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": "RGB888", - }, - buffer_count=self.PREVIEW_BUFFER_COUNT, - ) + video_config = self._create_video_configuration() self.camera.configure(video_config) except Exception: still_cfg = self.camera.create_still_configuration( @@ -638,9 +902,15 @@ def set_resolution( ) self.camera.configure(still_cfg) try: - self.camera.set_controls({"FrameRate": self.fps}) + self._apply_frame_duration_controls() except Exception: pass + try: + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_ae_flicker_controls() + except Exception as e: + logger.warning(f"重放白平衡控制失败(忽略): {e}") if need_reconfig and was_capturing: return self.start_capture() @@ -662,11 +932,7 @@ def set_fps(self, fps: int) -> bool: return False try: self.fps = int(max(1, fps)) - try: - self.camera.set_controls({"FrameRate": self.fps}) - except Exception: - # 不支持动态设置时也返回 True,后续通过重配生效 / True is also returned when dynamic setting is not supported, and will take effect later through reconfiguration. - pass + self._apply_frame_duration_controls() logger.info(f"帧率设置为: {self.fps}fps") return True except Exception as e: @@ -683,6 +949,7 @@ def set_exposure(self, exposure_us: int) -> bool: self.camera.set_controls({"AeEnable": False, "ExposureTime": exposure_us}) self.exposure_us = exposure_us self.auto_exposure = False + self._apply_frame_duration_controls() logger.info(f"曝光时间设置为: {exposure_us}μs") return True except Exception as e: @@ -712,6 +979,7 @@ def set_gain(self, analogue_gain: float, digital_gain: float = 1.0) -> bool: self.analogue_gain = analogue_gain self.digital_gain = digital_gain self.auto_exposure = False + self._apply_frame_duration_controls() logger.info(f"增益设置为: 模拟={analogue_gain}, 数字={digital_gain}") return True except Exception as e: @@ -730,6 +998,7 @@ def set_auto_exposure(self, enabled: bool) -> bool: self._apply_polar_auto_exposure_controls() else: self.camera.set_controls({"AeEnable": False}) + self._apply_frame_duration_controls() # 关闭自动曝光时,立即重放当前手动参数,确保状态一致 / When auto-exposure is turned off, the current manual parameters are immediately replayed to ensure consistent status. if not enabled: @@ -771,9 +1040,7 @@ def set_flip(self, flip_horizontal: bool, flip_vertical: bool) -> bool: return False self.flip_horizontal = bool(flip_horizontal) self.flip_vertical = bool(flip_vertical) - logger.info( - f"图像镜像: 水平={self.flip_horizontal}, 垂直={self.flip_vertical}" - ) + logger.info(f"图像镜像: 水平={self.flip_horizontal}, 垂直={self.flip_vertical}") return True def set_sampling_mode(self, mode: str) -> bool: @@ -817,13 +1084,7 @@ def set_sampling_mode(self, mode: str) -> bool: return False try: - video_config = self.camera.create_video_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": "RGB888", - }, - buffer_count=self.PREVIEW_BUFFER_COUNT, - ) + video_config = self._create_video_configuration() self.camera.configure(video_config) except Exception: still_cfg = self.camera.create_still_configuration( @@ -835,9 +1096,15 @@ def set_sampling_mode(self, mode: str) -> bool: self.camera.configure(still_cfg) try: - self.camera.set_controls({"FrameRate": self.fps}) + self._apply_frame_duration_controls() except Exception: pass + try: + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_ae_flicker_controls() + except Exception as e: + logger.warning(f"重放白平衡控制失败(忽略): {e}") if was_capturing: return self.start_capture() @@ -854,14 +1121,33 @@ def get_camera_info(self) -> dict[str, Any]: try: camera_properties = self.camera.camera_properties + metadata = self._last_metadata or {} + capabilities = self._camera_capabilities() return { + "driver": self.driver_name, + "backend": self.backend_name, + "capabilities": capabilities, "sensor": camera_properties.get("Model", "Unknown"), "resolution": f"{self.width}x{self.height}", "fps": self.fps, "exposure_us": self.exposure_us, + "actual_exposure_us": int( + metadata.get("ExposureTime", self.exposure_us) or 0 + ), + "frame_duration_us": int(metadata.get("FrameDuration", 0) or 0), + "frame_duration_limits": list( + self._frame_duration_limits or self._compute_frame_duration_limits() + ), "analogue_gain": self.analogue_gain, "digital_gain": self.digital_gain, + "actual_digital_gain": metadata.get("DigitalGain"), "auto_exposure": self.auto_exposure, + "auto_exposure_max_us": self.auto_exposure_max_us, + "ae_flicker_mode": self.ae_flicker_mode, + "noise_reduction_mode": self.noise_reduction_mode, + "noise_reduction": {"off": 0, "fast": 1, "high_quality": 2}.get( + self.noise_reduction_mode, 1 + ), "auto_gain": self.auto_gain, "rotation": self.rotation, "flip_horizontal": self.flip_horizontal, @@ -877,8 +1163,22 @@ def get_camera_info(self) -> dict[str, Any]: "white_balance_mode": self.white_balance_mode, "white_balance_gain_r": self.white_balance_gain_r, "white_balance_gain_b": self.white_balance_gain_b, + "actual_white_balance_gains": metadata.get("ColourGains"), + "awb_enabled": self.white_balance_mode + in {"auto", "daylight", "cloudy", "tungsten", "fluorescent", "indoor"}, + "colour_temperature": metadata.get("ColourTemperature"), + "lux": metadata.get("Lux"), + "sensor_timestamp": metadata.get("SensorTimestamp"), + "sensor_black_levels": metadata.get("SensorBlackLevels"), + "night_mode": self.night_mode, "ae_polar_preset": self.ae_polar_preset, "ae_exposure_value": self.ae_exposure_value, + "lores_enabled": self.lores_enabled, + "lores_available": self._lores_available, + "lores_width": self.lores_width, + "lores_height": self.lores_height, + "lores_format": self.lores_format, + "lores_stats": self._last_lores_stats, "control_ranges": self.get_manual_control_ranges(), } except Exception as e: @@ -902,9 +1202,7 @@ def get_image_quality_metrics(self) -> dict[str, Any]: gain_level = self.analogue_gain * self.digital_gain # 根据曝光时间判断夜间模式 / Determine night mode based on exposure time - night_mode = ( - self.exposure_us > 30000 - ) # 曝光时间超过30ms认为是夜间模式 / Exposure time longer than 30ms is considered night mode + night_mode = bool(self.night_mode) # 计算曝光充足度(基于曝光时间) / Calculate exposure adequacy (based on exposure time) # 假设10ms为基准曝光时间 / Assume 10ms as the base exposure time @@ -958,20 +1256,48 @@ def get_image_quality_metrics(self) -> dict[str, Any]: } def set_noise_reduction(self, level: int) -> bool: - """设置降噪级别 (0-4) / Set noise reduction level (0-4)""" + """兼容旧级别接口并映射到语义模式 / Compat level API mapped to semantic NR mode.""" + return self.set_noise_reduction_mode( + self._normalize_noise_reduction_mode(level) + ) + + def set_noise_reduction_mode(self, mode: str) -> bool: + """设置语义降噪模式 / Set semantic noise-reduction mode.""" if not self.is_initialized: logger.error("相机未初始化") return False try: - # 将级别映射到相机控制参数 / Map levels to camera control parameters - noise_reduction_mode = min(max(level, 0), 4) - self.camera.set_controls({"NoiseReductionMode": noise_reduction_mode}) - logger.info(f"降噪级别设置为: {noise_reduction_mode}") + self.noise_reduction_mode = self._normalize_noise_reduction_mode(mode) + self._apply_noise_reduction_controls() + logger.info(f"降噪模式设置为: {self.noise_reduction_mode}") return True except Exception as e: - logger.error(f"设置降噪级别失败: {e}") + logger.error(f"设置降噪模式失败: {e}") + return False + + def set_ae_flicker_mode(self, mode: str) -> bool: + """设置 AE 防闪烁模式 / Set AE flicker mode.""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + text = str(mode or "off").lower().replace("_", "") + self.ae_flicker_mode = ( + "50hz" + if text in {"50", "50hz"} + else "60hz" if text in {"60", "60hz"} else "off" + ) + self._apply_ae_flicker_controls() + return True + + def set_auto_exposure_max_us(self, value: int) -> bool: + """设置自动曝光最长帧周期 / Set maximum auto-exposure frame duration.""" + if not self.is_initialized: + logger.error("相机未初始化") return False + self.auto_exposure_max_us = max(10_000, min(10_000_000, int(value))) + self._apply_frame_duration_controls() + return True def set_white_balance( self, mode: str, gain_r: float = 1.0, gain_b: float = 1.0 @@ -982,26 +1308,30 @@ def set_white_balance( return False try: - if mode == "auto": - self.camera.set_controls({"AwbEnable": True}) - self.white_balance_mode = "auto" - logger.info("白平衡设置为自动模式") + mode = str(mode or "auto").lower() + if mode in { + "auto", + "daylight", + "cloudy", + "tungsten", + "fluorescent", + "indoor", + }: + self.white_balance_mode = mode + self.white_balance_gain_r = 1.0 + self.white_balance_gain_b = 1.0 + self._apply_white_balance_controls() + logger.info(f"白平衡设置为模式: {mode}") elif mode == "manual": - self.camera.set_controls( - {"AwbEnable": False, "ColourGains": (gain_r, gain_b)} - ) self.white_balance_mode = "manual" self.white_balance_gain_r = gain_r self.white_balance_gain_b = gain_b + self._apply_white_balance_controls() logger.info(f"白平衡设置为手动模式: R={gain_r}, B={gain_b}") elif mode == "night": # 夜间模式:稍微偏暖色调 / Night mode: Slightly warmer tones - self.camera.set_controls( - {"AwbEnable": False, "ColourGains": (1.1, 0.9)} - ) self.white_balance_mode = "night" - self.white_balance_gain_r = 1.1 - self.white_balance_gain_b = 0.9 + self._apply_white_balance_controls() logger.info("白平衡设置为夜间模式") else: logger.error(f"不支持的白平衡模式: {mode}") @@ -1067,19 +1397,20 @@ def set_night_mode(self, enabled: bool) -> bool: try: if enabled: # 夜间模式:提高增益,延长曝光时间,调整白平衡 / Night mode: increase gain, extend exposure time, adjust white balance + self.exposure_us = max(self.exposure_us, 30000) + self.analogue_gain = max(self.analogue_gain, 4.0) + self.white_balance_mode = "night" + self.noise_reduction_mode = "fast" self.camera.set_controls( { - "ExposureTime": max( - self.exposure_us, 30000 - ), # 至少30ms / At least 30ms - "AnalogueGain": max( - self.analogue_gain, 4.0 - ), # 至少4x增益 / At least 4x gain - "AwbEnable": False, - "ColourGains": (1.1, 0.9), # 偏暖色调 / warmer tones - "NoiseReductionMode": 2, # 中等降噪 / Moderate noise reduction + "ExposureTime": self.exposure_us, + "AnalogueGain": self.analogue_gain, } ) + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_frame_duration_controls() + self.night_mode = True logger.info("夜间模式已启用") else: # 关闭夜间模式:恢复默认设置 / Turn off night mode: restore default settings @@ -1087,10 +1418,15 @@ def set_night_mode(self, enabled: bool) -> bool: { "ExposureTime": self.exposure_us, "AnalogueGain": self.analogue_gain, - "AwbEnable": True, # 恢复自动白平衡 / Restore automatic white balance - "NoiseReductionMode": 0, # 关闭降噪 / Turn off noise reduction } ) + self.white_balance_mode = "auto" + self.white_balance_gain_r = 1.0 + self.white_balance_gain_b = 1.0 + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_frame_duration_controls() + self.night_mode = False logger.info("夜间模式已关闭") return True @@ -1117,22 +1453,17 @@ def set_color_mode(self, color_mode: str) -> bool: # 更新颜色模式 / Update color mode self.color_mode = color_mode - # 对于颜色模式,我们统一使用RGB888格式,在图像处理阶段进行转换 / For color mode, we uniformly use the RGB888 format and convert it during the image processing stage. - # 这样可以保持相机配置的一致性,避免格式兼容性问题 / This maintains consistency in camera configuration and avoids format compatibility issues - main_format = "RGB888" - - camera_config = self.camera.create_still_configuration( - main={ - "size": (self.capture_width, self.capture_height), - "format": main_format, - }, - raw={ - "size": (self.capture_width, self.capture_height), - "format": "SRGGB12", - }, - ) + # 颜色模式只影响输出转换,不改变主流 RGB888 配置 / Color mode only changes output conversion. + camera_config = self._create_video_configuration() self.camera.configure(camera_config) + try: + self._apply_white_balance_controls() + self._apply_noise_reduction_controls() + self._apply_ae_flicker_controls() + self._apply_frame_duration_controls() + except Exception as e: + logger.warning(f"重放白平衡控制失败(忽略): {e}") # 如果之前在捕获,重新开始 / If capturing before, start again if was_capturing: @@ -1156,6 +1487,9 @@ def create_camera( """创建相机实例 / Create camera instance""" if camera_type == "imx327_mipi": return IMX327MIPICamera(config) + if camera_type in {"linuxpy_v4l2", "v4l2_linuxpy"}: + # 预留自定义 Linux 入口;树莓派 CSI 默认仍走 Picamera2 / Reserved custom-Linux hook; Pi CSI stays Picamera2. + return LinuxpyV4L2Driver(config) # type: ignore[return-value] else: logger.error(f"不支持的相机类型: {camera_type}") return None diff --git a/ogscope/platform/hardware/gpio_config.py b/ogscope/platform/hardware/gpio_config.py index 2b6591c..89d850b 100644 --- a/ogscope/platform/hardware/gpio_config.py +++ b/ogscope/platform/hardware/gpio_config.py @@ -112,13 +112,6 @@ class RaspberryPiZero2WGPIO: "error_led_pin": 21, # 错误 LED / Error LED } - # WiFi 应急短接(BCM):输出低 + 上拉输入,短接 ≥2s 切 STA;物理排针 15–16 相邻 - # WiFi emergency short (BCM): OUT low + pull-up IN; hold ≥2s forces STA; physical pins 15–16 adjacent - WIFI_EMERGENCY_SHORT_PINS = { - "out_bcm": 22, - "in_bcm": 23, - } - class GPIOConfig: """GPIO 配置管理类 / GPIO configuration management class""" @@ -195,11 +188,7 @@ def get_pin_number(self, pin_name: str) -> Optional[int]: return self.gpio_config.GPIO_PINS.get(pin_name) def get_all_used_pins(self) -> list: - """获取所有已使用的引脚 / Get all used pins - - 注:WiFi 应急短接使用 BCM22/23,启用 `OGSCOPE_WIFI_EMERGENCY_GPIO_ENABLED` 时勿占用。 - Note: WiFi emergency uses BCM 22/23; avoid conflicts when emergency GPIO is enabled. - """ + """获取所有已使用的引脚 / Get all used pins.""" used_pins = [] # 显示屏引脚 / Display pins diff --git a/ogscope/platform/hardware/st7796_spi.py b/ogscope/platform/hardware/st7796_spi.py index 59f4394..ec0ee59 100644 --- a/ogscope/platform/hardware/st7796_spi.py +++ b/ogscope/platform/hardware/st7796_spi.py @@ -102,8 +102,44 @@ def _init_sequence(self) -> None: d(0xC2, [0xA7]) d(0xC5, [0x18]) time.sleep(0.12) - d(0xE0, [0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B]) - d(0xE1, [0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B]) + d( + 0xE0, + [ + 0xF0, + 0x09, + 0x0B, + 0x06, + 0x04, + 0x15, + 0x2F, + 0x54, + 0x42, + 0x3C, + 0x17, + 0x14, + 0x18, + 0x1B, + ], + ) + d( + 0xE1, + [ + 0xE0, + 0x09, + 0x0B, + 0x06, + 0x04, + 0x03, + 0x2B, + 0x43, + 0x42, + 0x3B, + 0x16, + 0x14, + 0x17, + 0x1B, + ], + ) time.sleep(0.12) d(0xF0, [0x3C]) d(0xF0, [0x69]) diff --git a/ogscope/platform/hardware/wifi_emergency_gpio.py b/ogscope/platform/hardware/wifi_emergency_gpio.py deleted file mode 100644 index 1e2ad88..0000000 --- a/ogscope/platform/hardware/wifi_emergency_gpio.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -WiFi 应急 GPIO 监控:短接 2s 强制切回 STA -WiFi emergency GPIO watcher: short pins to force STA. -""" - -from __future__ import annotations - -import threading -import time -from dataclasses import dataclass - -from loguru import logger - -from ogscope.config import Settings, get_settings -from ogscope.platform.hardware.wifi_switch import wifi_switch_service -from ogscope.utils.environment import is_raspberry_pi - - -@dataclass -class _WatcherState: - low_since: float | None = None - last_trigger_at: float = 0.0 - - -class WifiEmergencyGpioMonitor: - """应急 GPIO 监控器 / Emergency GPIO monitor.""" - - def __init__(self, settings: Settings | None = None) -> None: - self._settings = settings or get_settings() - self._thread: threading.Thread | None = None - self._stop_event = threading.Event() - self._gpio = None - self._state = _WatcherState() - - def start(self) -> None: - """启动监控线程 / Start monitor thread.""" - if not self._settings.wifi_emergency_gpio_enabled: - logger.info("应急 GPIO 未启用 / Emergency GPIO disabled by config") - return - if self._thread and self._thread.is_alive(): - return - if not is_raspberry_pi(): - logger.info("非树莓派环境,跳过应急 GPIO / Skip emergency GPIO on non-RPi") - return - try: - import RPi.GPIO as gpio # type: ignore - except Exception as e: - logger.warning( - "未安装 RPi.GPIO,无法启用应急短接 / RPi.GPIO unavailable: {}", e - ) - return - - self._gpio = gpio - self._setup_gpio() - self._stop_event.clear() - self._thread = threading.Thread( - target=self._run_loop, - name="wifi-emergency-gpio", - daemon=True, - ) - self._thread.start() - logger.info( - "应急 GPIO 已启动 / Emergency GPIO monitor started: out={}, in={}, hold={}s", - self._settings.wifi_emergency_pin_out_bcm, - self._settings.wifi_emergency_pin_in_bcm, - self._settings.wifi_emergency_hold_seconds, - ) - - def stop(self) -> None: - """停止监控线程并释放 GPIO / Stop monitor and cleanup GPIO.""" - self._stop_event.set() - if self._thread and self._thread.is_alive(): - self._thread.join(timeout=1.5) - self._thread = None - if self._gpio: - try: - self._gpio.cleanup( - [ - self._settings.wifi_emergency_pin_out_bcm, - self._settings.wifi_emergency_pin_in_bcm, - ] - ) - except Exception: - pass - self._gpio = None - logger.info("应急 GPIO 已停止 / Emergency GPIO monitor stopped") - - def _setup_gpio(self) -> None: - assert self._gpio is not None - g = self._gpio - g.setwarnings(False) - g.setmode(g.BCM) - g.setup(self._settings.wifi_emergency_pin_out_bcm, g.OUT, initial=g.LOW) - g.setup(self._settings.wifi_emergency_pin_in_bcm, g.IN, pull_up_down=g.PUD_UP) - - def _run_loop(self) -> None: - assert self._gpio is not None - g = self._gpio - interval = 0.05 - hold = self._settings.wifi_emergency_hold_seconds - while not self._stop_event.is_set(): - now = time.monotonic() - pin_low = g.input(self._settings.wifi_emergency_pin_in_bcm) == g.LOW - if pin_low: - if self._state.low_since is None: - self._state.low_since = now - if (now - self._state.low_since) >= hold: - if (now - self._state.last_trigger_at) >= hold: - self._state.last_trigger_at = now - self._force_sta() - else: - self._state.low_since = None - time.sleep(interval) - - def _force_sta(self) -> None: - logger.warning( - "检测到应急短接,强制切换 STA / Emergency short detected, forcing STA" - ) - if not wifi_switch_service.is_configured(): - logger.error( - "WiFi 未配置,无法应急切 STA / WiFi not configured, cannot force STA" - ) - return - try: - wifi_switch_service.switch("sta") - except Exception as e: - logger.error("应急切 STA 失败 / Failed to force STA: {}", e) - - -wifi_emergency_gpio_monitor = WifiEmergencyGpioMonitor() diff --git a/ogscope/platform/hardware_plane/__init__.py b/ogscope/platform/hardware_plane/__init__.py index b2f9f9b..e96ef1a 100644 --- a/ogscope/platform/hardware_plane/__init__.py +++ b/ogscope/platform/hardware_plane/__init__.py @@ -21,4 +21,3 @@ "start_hardware_plane", "stop_hardware_plane", ] - diff --git a/ogscope/platform/hardware_plane/client.py b/ogscope/platform/hardware_plane/client.py index bd05246..cdd18e2 100644 --- a/ogscope/platform/hardware_plane/client.py +++ b/ogscope/platform/hardware_plane/client.py @@ -43,7 +43,9 @@ def __init__( self._daemon = daemon self._default_timeout_ms = max(50, int(default_timeout_ms)) self._remote_sensor_transport = remote_sensor_transport - self._remote_sensor_enabled = bool(remote_sensor_enabled and remote_sensor_transport) + self._remote_sensor_enabled = bool( + remote_sensor_enabled and remote_sensor_transport + ) self._runtime_profile = dict(runtime_profile or {}) async def _call( @@ -108,4 +110,3 @@ async def event_subscribe(self, topic: str) -> dict[str, Any]: def runtime_profile(self) -> dict[str, Any]: """运行时角色信息 / Runtime role profile.""" return dict(self._runtime_profile) - diff --git a/ogscope/platform/hardware_plane/daemon.py b/ogscope/platform/hardware_plane/daemon.py index 6fa2c0c..3dbabb4 100644 --- a/ogscope/platform/hardware_plane/daemon.py +++ b/ogscope/platform/hardware_plane/daemon.py @@ -195,9 +195,7 @@ async def handle_call( code=PlaneErrorCode.UNAVAILABLE, message="local sensor service is disabled; use delegated sensor backend", ) - return ok_payload( - {"sensor": await sensor_hub.read(sensor_name)} - ) + return ok_payload({"sensor": await sensor_hub.read(sensor_name)}) if method == PlaneMethod.DEVICE_COMMAND.value: target = str(params.get("target", "")) action = str(params.get("action", "")) @@ -257,4 +255,3 @@ async def status(self) -> dict[str, Any]: def metrics(self) -> dict[str, Any]: return self._metrics.to_dict() - diff --git a/ogscope/platform/hardware_plane/registry.py b/ogscope/platform/hardware_plane/registry.py index e8a0eb0..124f7dc 100644 --- a/ogscope/platform/hardware_plane/registry.py +++ b/ogscope/platform/hardware_plane/registry.py @@ -55,9 +55,8 @@ def update_state(self, name: str, state: CapabilityState) -> None: def list_records(self) -> list[CapabilityRecord]: """列出所有能力 / List all capabilities.""" with self._lock: - return [record for record in self._records.values()] + return list(self._records.values()) def as_dict_list(self) -> list[dict[str, Any]]: """字典列表表示 / Dict-list representation.""" return [record.to_dict() for record in self.list_records()] - diff --git a/ogscope/platform/hardware_plane/runtime.py b/ogscope/platform/hardware_plane/runtime.py index 0c4ecfd..72ff19c 100644 --- a/ogscope/platform/hardware_plane/runtime.py +++ b/ogscope/platform/hardware_plane/runtime.py @@ -73,7 +73,9 @@ def _ensure_runtime(settings: Settings) -> None: ) remote_sensor_transport = None if profile["subordinate_mode"]: - remote_sensor_transport = JsonRpcUdsClient(str(settings.hardware_plane_remote_uds_socket)) + remote_sensor_transport = JsonRpcUdsClient( + str(settings.hardware_plane_remote_uds_socket) + ) _client = HardwarePlaneClient( _daemon, default_timeout_ms=settings.hardware_plane_rpc_timeout_ms, @@ -125,4 +127,3 @@ async def stop_hardware_plane() -> None: """停止硬件平面 / Stop hardware plane.""" daemon = get_hardware_plane_daemon() await daemon.stop() - diff --git a/ogscope/platform/hardware_plane/services/__init__.py b/ogscope/platform/hardware_plane/services/__init__.py index 6bf89ec..1bd2ec1 100644 --- a/ogscope/platform/hardware_plane/services/__init__.py +++ b/ogscope/platform/hardware_plane/services/__init__.py @@ -7,4 +7,3 @@ from ogscope.platform.hardware_plane.services.sensor_hub import SensorHubService __all__ = ["CameraPlaneService", "HmiService", "SensorHubService"] - diff --git a/ogscope/platform/hardware_plane/services/base.py b/ogscope/platform/hardware_plane/services/base.py index ab24826..4702048 100644 --- a/ogscope/platform/hardware_plane/services/base.py +++ b/ogscope/platform/hardware_plane/services/base.py @@ -21,6 +21,7 @@ async def stop(self) -> None: async def status(self) -> dict[str, Any]: """读取服务状态 / Read service status.""" - async def command(self, action: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: + async def command( + self, action: str, payload: dict[str, Any] | None = None + ) -> dict[str, Any]: """执行命令 / Execute command.""" - diff --git a/ogscope/platform/hardware_plane/services/camera_service.py b/ogscope/platform/hardware_plane/services/camera_service.py index a687fa2..7ea4364 100644 --- a/ogscope/platform/hardware_plane/services/camera_service.py +++ b/ogscope/platform/hardware_plane/services/camera_service.py @@ -88,4 +88,3 @@ async def command( frame.pop("payload", None) return frame return {"accepted": False, "message": f"unsupported action: {action}"} - diff --git a/ogscope/platform/hardware_plane/services/hmi.py b/ogscope/platform/hardware_plane/services/hmi.py index 2b129e5..7fd629d 100644 --- a/ogscope/platform/hardware_plane/services/hmi.py +++ b/ogscope/platform/hardware_plane/services/hmi.py @@ -73,9 +73,13 @@ def _ensure_display_sync(self) -> Any: "display_disabled:在环境变量或 .env 中设置 OGSCOPE_DISPLAY_ENABLED=true" ) if settings.display_type.lower() != "st7796": - raise RuntimeError(f"unsupported display_type: {settings.display_type!r} (expected st7796)") + raise RuntimeError( + f"unsupported display_type: {settings.display_type!r} (expected st7796)" + ) if sys.platform != "linux": - raise RuntimeError("ST7796 仅支持 Linux(树莓派)/ ST7796 requires Linux (Raspberry Pi)") + raise RuntimeError( + "ST7796 仅支持 Linux(树莓派)/ ST7796 requires Linux (Raspberry Pi)" + ) if self._display is not None: return self._display from ogscope.platform.hardware.st7796_spi import ST7796SPI diff --git a/ogscope/platform/hardware_plane/services/sensor_hub.py b/ogscope/platform/hardware_plane/services/sensor_hub.py index 5eea6b8..67770e1 100644 --- a/ogscope/platform/hardware_plane/services/sensor_hub.py +++ b/ogscope/platform/hardware_plane/services/sensor_hub.py @@ -63,4 +63,3 @@ async def command( self._running = True return {"accepted": True, "message": "sensor hub restarted"} return {"accepted": False, "message": f"unsupported action: {action}"} - diff --git a/ogscope/platform/hardware_plane/transport/__init__.py b/ogscope/platform/hardware_plane/transport/__init__.py index 3b55b41..1e20b7c 100644 --- a/ogscope/platform/hardware_plane/transport/__init__.py +++ b/ogscope/platform/hardware_plane/transport/__init__.py @@ -8,4 +8,3 @@ ) __all__ = ["JsonRpcUdsServer", "JsonRpcUdsClient"] - diff --git a/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py b/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py index 7515534..35c5197 100644 --- a/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py +++ b/ogscope/platform/hardware_plane/transport/jsonrpc_uds.py @@ -96,13 +96,18 @@ async def call( "method": method, "params": params or {}, } - writer.write((json.dumps(request, ensure_ascii=False) + "\n").encode("utf-8")) + writer.write( + (json.dumps(request, ensure_ascii=False) + "\n").encode("utf-8") + ) await asyncio.wait_for(writer.drain(), timeout=budget_s) line = await asyncio.wait_for(reader.readline(), timeout=budget_s) if not line: - return {"success": False, "error": {"message": "empty response"}, "data": {}} + return { + "success": False, + "error": {"message": "empty response"}, + "data": {}, + } return json.loads(line.decode("utf-8", errors="ignore")) finally: writer.close() await writer.wait_closed() - diff --git a/ogscope/web/api/analysis/routes.py b/ogscope/web/api/analysis/routes.py index 8ff2314..114b812 100644 --- a/ogscope/web/api/analysis/routes.py +++ b/ogscope/web/api/analysis/routes.py @@ -8,6 +8,7 @@ from fastapi.responses import FileResponse, PlainTextResponse from ogscope.domain.analysis.services import analysis_domain_service +from ogscope.web.api.analysis.services import analysis_service from ogscope.web.api.models.schemas import ( AnalysisBatchSolveRequest, AnalysisExperimentCreate, @@ -19,7 +20,6 @@ AnalysisSolveVideoFrameRequest, ImportFromDebugRequest, ) -from ogscope.web.api.analysis.services import analysis_service router = APIRouter() @@ -287,7 +287,9 @@ async def solve_uploaded_frame( """上传单帧 JPEG/PNG 并解算 / Solve a single uploaded frame (multipart).""" try: raw = await file.read() - payload_dict, extras = analysis_domain_service.parse_frame_upload_payload(payload) + payload_dict, extras = analysis_domain_service.parse_frame_upload_payload( + payload + ) data = AnalysisSolveImageRequest.model_validate(payload_dict) return await analysis_service.solve_uploaded_frame( image_bytes=raw, diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py index f14fc70..5667e3e 100644 --- a/ogscope/web/api/analysis/services.py +++ b/ogscope/web/api/analysis/services.py @@ -25,6 +25,7 @@ centroid_extraction_preview, merge_centroid_params, ) +from ogscope.algorithms.plate_solve.sensor_context import attach_sensor_prediction from ogscope.algorithms.star_extract import StarExtractor from ogscope.config import ( effective_solver_max_image_side, @@ -191,8 +192,8 @@ async def _try_enter_realtime_gate( "gate_reason": "previous request still running", "next_allowed_in_ms": 0, } - if state.last_finished_mono > 0: - elapsed = now - state.last_finished_mono + if state.last_started_mono > 0 and state.last_finished_mono > 0: + elapsed = now - state.last_started_mono if elapsed < interval: wait_ms = max(0, int((interval - elapsed) * 1000.0)) return { @@ -224,9 +225,9 @@ def _resolve_realtime_interval_ms( self, requested_ms: int | None ) -> tuple[int, int]: """解析实时解算间隔并按系统上下限裁剪 / Resolve realtime interval with server bounds.""" - if requested_ms is None: - return 0, 0 settings = get_settings() + if requested_ms is None: + requested_ms = round(1000.0 / max(0.01, settings.star_analysis_target_fps)) min_interval_ms = int(settings.star_analysis_min_interval_ms) max_interval_ms = int(settings.star_analysis_max_interval_ms) requested_interval_ms = int(requested_ms) @@ -333,10 +334,12 @@ def _build_polar_guide(self, row: dict[str, Any]) -> dict[str, Any] | None: east_deg = d_ra * math.cos(math.radians(dec_center)) north_deg = d_dec roll_rad = math.radians(roll) - x_deg = east_deg * math.cos(roll_rad) + north_deg * math.sin(roll_rad) - y_deg = -east_deg * math.sin(roll_rad) + north_deg * math.cos(roll_rad) + # Image x right, y down; Tetra3 Roll is CCW from image up (y→0). + x_deg = east_deg * math.cos(roll_rad) - north_deg * math.sin(roll_rad) + y_deg = east_deg * math.sin(roll_rad) + north_deg * math.cos(roll_rad) - px_per_deg = (min(w, h) / max(fov, 1e-6)) if fov > 0 else 1.0 + # Tetra3 FOV is horizontal; scale pixels per degree by frame width. + px_per_deg = (w / max(fov, 1e-6)) if fov > 0 else 1.0 dx_px = x_deg * px_per_deg dy_px = -y_deg * px_per_deg cx = w * 0.5 @@ -366,6 +369,36 @@ def _build_polar_guide(self, row: dict[str, Any]) -> dict[str, Any] | None: "angular_sep_deg": angular_sep_deg, } + def _attach_overlay_ext( + self, + row: dict[str, Any], + *, + overlay_topn_count: int | None = None, + enable_polar_guide: bool | None = None, + ) -> None: + """为解算结果附加 overlay_ext(Top-N 标注与极轴引导)/ Attach overlay_ext to solve row.""" + topn = ( + int(overlay_topn_count) + if overlay_topn_count is not None + else self._overlay_topn_default + ) + enable_polar = ( + bool(enable_polar_guide) + if enable_polar_guide is not None + else self._polar_guide_default + ) + overlay_ext: dict[str, Any] = {} + try: + overlay_ext["labels_topn"] = self._build_topn_labels(row, topn_count=topn) + except Exception: + overlay_ext["labels_topn"] = [] + if enable_polar: + try: + overlay_ext["polar_guide"] = self._build_polar_guide(row) + except Exception: + overlay_ext["polar_guide"] = None + row["overlay_ext"] = overlay_ext + def _centroid_params_from_payload( self, payload: CentroidParamsPayload | None ) -> CentroidExtractionParams | None: @@ -637,6 +670,7 @@ def _run_single() -> list[dict[str, Any]]: max_stars=max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) def _run_two_stage() -> list[dict[str, Any]]: @@ -659,6 +693,7 @@ def _run_two_stage() -> list[dict[str, Any]]: max_stars=speed_max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) row0 = first[0] if first else None if row0 and row0.get("status") == "MATCH_FOUND": @@ -688,6 +723,7 @@ def _run_two_stage() -> list[dict[str, Any]]: max_stars=robust_max_stars, large_scale_bg_subtract=ls_bg, centroid_rejection_level=cr_lv, + solve_context=body.solve_context, ) if second: second[0]["solve_profile"] = "robust" @@ -713,6 +749,11 @@ def _run_two_stage() -> list[dict[str, Any]]: if row and detail_level != "full": row.pop("tetra", None) if row: + self._attach_overlay_ext( + row, + overlay_topn_count=getattr(body, "overlay_topn_count", None), + enable_polar_guide=getattr(body, "enable_polar_guide", None), + ) self._lab.update_last_solve( source.name, self._metrics_from_solve_row(row), @@ -966,6 +1007,7 @@ def _run() -> dict[str, Any]: self._clamp_centroid_rejection_level( solve_params.centroid_rejection_level ), + solve_context=solve_params.solve_context, ) hard_timeout_sec = max( @@ -975,30 +1017,11 @@ def _run() -> dict[str, Any]: loop.run_in_executor(self._solver_executor, _run), timeout=hard_timeout_sec, ) - # 统一 overlay_ext 结构,便于前端复用渲染逻辑 - topn = ( - int(overlay_topn_count) - if overlay_topn_count is not None - else self._overlay_topn_default + self._attach_overlay_ext( + row, + overlay_topn_count=overlay_topn_count, + enable_polar_guide=enable_polar_guide, ) - enable_polar = ( - bool(enable_polar_guide) - if enable_polar_guide is not None - else self._polar_guide_default - ) - overlay_ext: dict[str, Any] = {} - try: - overlay_ext["labels_topn"] = self._build_topn_labels( - row, topn_count=topn - ) - except Exception: - overlay_ext["labels_topn"] = [] - if enable_polar: - try: - overlay_ext["polar_guide"] = self._build_polar_guide(row) - except Exception: - overlay_ext["polar_guide"] = None - row["overlay_ext"] = overlay_ext row["solve_profile"] = effective_profile row["t_backend_total_ms"] = round( (time.perf_counter() - t_total) * 1000.0, 3 @@ -1012,6 +1035,12 @@ def _run() -> dict[str, Any]: "gate_status": "SOLVED", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 + ), + ), } except asyncio.TimeoutError: return { @@ -1028,6 +1057,12 @@ def _run() -> dict[str, Any]: "gate_reason": "outer request timeout", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 + ), + ), } finally: await self._leave_realtime_gate("file_upload") @@ -1118,6 +1153,7 @@ def _solve_bgr_to_row( max_stars: int | None = None, large_scale_bg_subtract: bool = False, centroid_rejection_level: int | None = None, + solve_context: Any | None = None, ) -> dict[str, Any]: """BGR 帧送 Tetra3 解算 / Plate-solve one BGR frame.""" cr_level = self._clamp_centroid_rejection_level( @@ -1145,7 +1181,9 @@ def _solve_bgr_to_row( large_scale_bg_subtract=large_scale_bg_subtract, centroid_rejection_level=cr_level, ) - return {"frame_index": 0, **solved.to_dict()} + row = {"frame_index": 0, **solved.to_dict()} + attach_sensor_prediction(row, solve_context) + return row def _analyze_image( self, @@ -1160,6 +1198,7 @@ def _analyze_image( max_stars: int | None = None, large_scale_bg_subtract: bool = False, centroid_rejection_level: int | None = None, + solve_context: Any | None = None, ) -> list[dict[str, Any]]: """分析单图 / Analyze image""" t_total = time.perf_counter() @@ -1180,6 +1219,7 @@ def _analyze_image( max_stars=max_stars, large_scale_bg_subtract=large_scale_bg_subtract, centroid_rejection_level=centroid_rejection_level, + solve_context=solve_context, ) row["t_open_decode_ms"] = round(t_open_decode_ms, 3) row["t_backend_total_ms"] = round((time.perf_counter() - t_total) * 1000.0, 3) @@ -1317,7 +1357,9 @@ async def solve_video_frame( ) loop = asyncio.get_running_loop() - cr_frame = self._clamp_centroid_rejection_level(body.centroid_rejection_level) + cr_frame = self._clamp_centroid_rejection_level( + body.centroid_rejection_level + ) def _run() -> dict[str, Any]: return self._solve_bgr_to_row( @@ -1332,6 +1374,7 @@ def _run() -> dict[str, Any]: max_stars, bool(body.large_scale_bg_subtract), cr_frame, + solve_context=body.solve_context, ) hard_timeout_sec = max( @@ -1342,29 +1385,11 @@ def _run() -> dict[str, Any]: timeout=hard_timeout_sec, ) # 二次分析与极轴引导(失败降级,不影响基础解算) - topn = ( - int(body.overlay_topn_count) - if getattr(body, "overlay_topn_count", None) is not None - else self._overlay_topn_default + self._attach_overlay_ext( + row, + overlay_topn_count=getattr(body, "overlay_topn_count", None), + enable_polar_guide=getattr(body, "enable_polar_guide", None), ) - enable_polar = ( - bool(body.enable_polar_guide) - if getattr(body, "enable_polar_guide", None) is not None - else self._polar_guide_default - ) - overlay_ext: dict[str, Any] = {} - try: - overlay_ext["labels_topn"] = self._build_topn_labels( - row, topn_count=topn - ) - except Exception: - overlay_ext["labels_topn"] = [] - if enable_polar: - try: - overlay_ext["polar_guide"] = self._build_polar_guide(row) - except Exception: - overlay_ext["polar_guide"] = None - row["overlay_ext"] = overlay_ext if t_open_decode_ms is not None: row["t_open_decode_ms"] = round(t_open_decode_ms, 3) elapsed_ms = (time.perf_counter() - t_total) * 1000.0 @@ -1388,7 +1413,7 @@ def _run() -> dict[str, Any]: ), "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, - "next_allowed_in_ms": effective_interval_ms, + "next_allowed_in_ms": max(0, int(effective_interval_ms - elapsed_ms)), } except asyncio.TimeoutError: return { @@ -1408,7 +1433,12 @@ def _run() -> dict[str, Any]: "gate_reason": "outer request timeout", "requested_interval_ms": requested_interval_ms, "effective_interval_ms": effective_interval_ms, - "next_allowed_in_ms": effective_interval_ms, + "next_allowed_in_ms": max( + 0, + int( + effective_interval_ms - (time.perf_counter() - t_total) * 1000.0 + ), + ), } finally: await self._leave_realtime_gate(gate_source_key) @@ -1430,6 +1460,7 @@ def lab_public_settings(self) -> dict[str, Any]: "camera_width": s.camera_width, "camera_height": s.camera_height, "camera_fps": s.camera_fps, + "shared_preview_fps": s.shared_preview_fps, "solver_fov_deg": s.solver_fov_deg, "solver_max_image_side": s.solver_max_image_side, "solver_large_scale_bg_downsample": s.solver_large_scale_bg_downsample, diff --git a/ogscope/web/api/core/routes.py b/ogscope/web/api/core/routes.py index 4a53330..53152d6 100644 --- a/ogscope/web/api/core/routes.py +++ b/ogscope/web/api/core/routes.py @@ -25,7 +25,9 @@ "/core/v1/analysis/start", response_model=CoreAnalysisControlResponse, ) -async def core_start_analysis(body: CoreStartAnalysisRequest) -> CoreAnalysisControlResponse: +async def core_start_analysis( + body: CoreStartAnalysisRequest, +) -> CoreAnalysisControlResponse: """开始分析(Core 标准契约)/ Start analysis (Core contract).""" try: data = await core_contract_service.start_analysis( @@ -34,6 +36,7 @@ async def core_start_analysis(body: CoreStartAnalysisRequest) -> CoreAnalysisCon fov_estimate=body.fov_estimate, fov_max_error=body.fov_max_error, solve_timeout_ms=body.solve_timeout_ms, + solve_context=body.solve_context, ) return CoreAnalysisControlResponse(**data) except Exception as exc: # noqa: BLE001 @@ -113,7 +116,9 @@ async def core_camera_stop() -> CoreCameraControlResponse: async def core_camera_tune(payload: CoreCameraTuneRequest) -> CoreCameraControlResponse: """微调相机参数(Core 标准契约)/ Tune camera settings (Core contract).""" try: - data = await core_contract_service.tune_camera(payload.model_dump(exclude_none=True)) + data = await core_contract_service.tune_camera( + payload.model_dump(exclude_none=True) + ) return CoreCameraControlResponse(**data) except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=500, detail=str(exc)) from exc diff --git a/ogscope/web/api/debug/magnetometer_service.py b/ogscope/web/api/debug/magnetometer_service.py index 3e15859..8788bbf 100644 --- a/ogscope/web/api/debug/magnetometer_service.py +++ b/ogscope/web/api/debug/magnetometer_service.py @@ -35,7 +35,12 @@ def _smbus_read_wia(bus: int, addr7: int) -> dict[str, Any]: try: from smbus2 import SMBus except ImportError: - return {"ok": False, "error": "smbus2 not installed", "wia1": None, "wia2": None} + return { + "ok": False, + "error": "smbus2 not installed", + "wia1": None, + "wia2": None, + } path = f"/dev/i2c-{bus}" if not os.path.exists(path): @@ -60,9 +65,7 @@ def _read() -> tuple[int | None, int | None, str | None]: wia1, wia2, err = _read() if err: return {"ok": False, "error": err, "wia1": None, "wia2": None} - match = ( - wia1 == _AKM_WIA1 and wia2 is not None and int(wia2) in _KNOWN_WIA2 - ) + match = wia1 == _AKM_WIA1 and wia2 is not None and int(wia2) in _KNOWN_WIA2 return { "ok": True, "error": None, @@ -96,6 +99,7 @@ def _smbus_read_wia_first_matching( class MagnetometerDebugService: """AK09911 系列探针与总线扫描 / AK09911 family probe and bus scan.""" + _xy_calib: dict[tuple[int, int], dict[str, Any]] = {} _heading_mode: dict[tuple[int, int], str] = {} _heading_locked: dict[tuple[int, int], dict[str, Any]] = {} @@ -226,14 +230,22 @@ async def calibration_commit(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, An d = ((d + 180.0) % 360.0) - 180.0 unwrapped += d prev_u = unwrapped - trend = unwrapped - ((math.degrees(math.atan2( - *MagnetometerDebugService._pair_values( - axes, - float(hx_hist[0]) - cx, - float(hy_hist[0]) - cy, - float(hz_hist[0]) - cz, + trend = unwrapped - ( + ( + math.degrees( + math.atan2( + *MagnetometerDebugService._pair_values( + axes, + float(hx_hist[0]) - cx, + float(hy_hist[0]) - cy, + float(hz_hist[0]) - cz, + ) + ) + ) + + 360.0 ) - )) + 360.0) % 360.0) + % 360.0 + ) sign = 1 if trend >= 0 else -1 locked = { @@ -284,7 +296,11 @@ async def calibration_status(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, An "addr_7bit": int(k[1]), "addr_7bit_hex": f"0x{int(k[1]):02x}", "samples": int(float(st.get("samples", 0.0))), - "span_xyz": {"x": round(span_x, 3), "y": round(span_y, 3), "z": round(span_z, 3)}, + "span_xyz": { + "x": round(span_x, 3), + "y": round(span_y, 3), + "z": round(span_z, 3), + }, "locked": locked, } @@ -416,9 +432,7 @@ async def probe_address_on_buses( _smbus_read_wia_first_matching, b, addr7 ) results.append({"bus": b, "addr_7bit_used": int(used), **w}) - any_ok = any( - r.get("ok") and r.get("matches_ak099xx") for r in results - ) + any_ok = any(r.get("ok") and r.get("matches_ak099xx") for r in results) return { "success": any_ok, "addr_7bit": int(addr7), @@ -530,9 +544,8 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: lz = hz - float(c.get("z", cz)) la, lb = MagnetometerDebugService._pair_values(axes_locked, lx, ly, lz) heading_deg_locked = ( - (math.degrees(math.atan2(sign_locked * la, lb)) + offset_locked + 360.0) - % 360.0 - ) + math.degrees(math.atan2(sign_locked * la, lb)) + offset_locked + 360.0 + ) % 360.0 heading_deg = heading_deg_locked heading_source = f"locked_{axes_locked}" return { @@ -548,7 +561,9 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: "heading_raw_deg": round(heading_raw_deg, 2), "heading_calibrated_deg": round(heading_cal_deg, 2), "heading_auto_deg": round(heading_auto_deg, 2), - "heading_locked_deg": None if heading_deg_locked is None else round(heading_deg_locked, 2), + "heading_locked_deg": ( + None if heading_deg_locked is None else round(heading_deg_locked, 2) + ), "heading_source": heading_source, "heading_axes_auto": auto_axes, "heading_mode": mode, @@ -580,4 +595,3 @@ async def sample_heading(*, bus: int = 1, addr7: int = 0x0C) -> dict[str, Any]: "calibration_samples": int(float(st.get("samples", 0.0))), "calibration_locked": locked, } - diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index ff030f7..320b480 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -8,6 +8,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse, StreamingResponse +from ogscope.config import get_settings from ogscope.core.application import core_contract_service from ogscope.core.realtime import realtime_solve_service from ogscope.domain.camera.services import ( @@ -36,9 +37,6 @@ "已达到 MJPEG 同时连接上限,请关闭其他标签页的预览" ) -_DEFAULT_PREVIEW_JPEG_QUALITY = 75 - - # ==================== 相机控制 ==================== / ==================== Camera Control ==================== @@ -106,12 +104,13 @@ async def _streaming_response_debug_camera_mjpeg( @router.get("/debug/camera/stream") async def stream_debug_camera( request: Request, - quality: int = Query(_DEFAULT_PREVIEW_JPEG_QUALITY, ge=10, le=100), + quality: int | None = Query(None, ge=10, le=100), ): """MJPEG 实时流 - 可配置压缩质量 / MJPEG live streaming - configurable compression quality""" try: + effective_quality = int(quality or get_settings().preview_jpeg_quality) return await _streaming_response_debug_camera_mjpeg( - request, image_format="jpeg", quality=quality + request, image_format="jpeg", quality=effective_quality ) except HTTPException: raise @@ -235,6 +234,15 @@ async def set_camera_fps(fps: int = Query(..., gt=0)): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/debug/camera/preview-fps") +async def set_camera_preview_fps(fps: int = Query(..., ge=1, le=30)): + """独立设置共享预览帧率 / Set shared preview FPS independently.""" + from ogscope.web.camera_shared import get_camera_manager + + applied = get_camera_manager().set_preview_fps(fps) + return {"success": True, "preview_target_fps": applied} + + @router.post("/debug/camera/settings") async def update_debug_camera_settings(settings: CameraSettings): """更新调试相机设置 / Update debug camera settings""" diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index 0177e4b..a01dc6a 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -45,6 +45,13 @@ "analogue_gain": "OGSCOPE_CAMERA_GAIN", "flip_horizontal": "OGSCOPE_CAMERA_FLIP_HORIZONTAL", "flip_vertical": "OGSCOPE_CAMERA_FLIP_VERTICAL", + "white_balance_mode": "OGSCOPE_CAMERA_WHITE_BALANCE_MODE", + "white_balance_gain_r": "OGSCOPE_CAMERA_WHITE_BALANCE_GAIN_R", + "white_balance_gain_b": "OGSCOPE_CAMERA_WHITE_BALANCE_GAIN_B", + "night_mode": "OGSCOPE_CAMERA_NIGHT_MODE", + "auto_exposure_max_us": "OGSCOPE_CAMERA_AUTO_EXPOSURE_MAX_US", + "ae_flicker_mode": "OGSCOPE_CAMERA_AE_FLICKER_MODE", + "noise_reduction_mode": "OGSCOPE_CAMERA_NOISE_REDUCTION_MODE", } # 串行化 ensure/start,避免并发 to_thread 竞争;与阻塞相机调用分离出事件循环 @@ -432,8 +439,17 @@ async def capture_image(): stem = generate_capture_stem("IMG", camera_info) image_path = DEBUG_CAPTURES_DIR / f"{stem}.jpg" - # 保存图像 / save image - success = cv2.imwrite(str(image_path), image) + # 相机输出为 RGB888,OpenCV 写文件前需要转 BGR,避免红蓝通道互换。 + # Camera output is RGB888; convert to BGR before OpenCV writes files to avoid R/B swap. + image_for_write = image + try: + if getattr(image, "ndim", 0) == 3 and int(image.shape[2]) >= 3: + image_for_write = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + except Exception: + image_for_write = image + + # 保存图像 / Save image + success = cv2.imwrite(str(image_path), image_for_write) if not success: raise Exception("图像保存失败") @@ -532,9 +548,9 @@ async def start_recording(): except ImportError: pass - camera = get_camera_instance() - if not camera or not camera.is_capturing: - raise Exception("相机未运行") + manager = get_camera_manager() + await manager.acquire_recording_consumer() + camera = manager.get_camera_instance() try: import cv2 @@ -611,8 +627,10 @@ async def record_video(): "path": str(video_path), } except ImportError: + await manager.release_recording_consumer() raise Exception("OpenCV未安装") except Exception as e: + await manager.release_recording_consumer() raise Exception(f"录制启动失败: {str(e)}") @staticmethod @@ -679,6 +697,7 @@ async def stop_recording(): recording_media_filename = None recording_codec_fourcc = "MJPG" recording_container = "AVI" + await get_camera_manager().release_recording_consumer() return { "success": True, @@ -946,10 +965,21 @@ async def update_settings(settings: dict[str, Any]): ) # 更新降噪设置 / Update noise reduction settings - if "noiseReduction" in settings: + if "noiseReductionMode" in settings: + if hasattr(camera, "set_noise_reduction_mode"): + camera.set_noise_reduction_mode(settings["noiseReductionMode"]) + if "noiseReduction" in settings and "noiseReductionMode" not in settings: if hasattr(camera, "set_noise_reduction"): camera.set_noise_reduction(settings["noiseReduction"]) + # 更新 libcamera 高级控制 / Update advanced libcamera controls + if "aeFlickerMode" in settings: + if hasattr(camera, "set_ae_flicker_mode"): + camera.set_ae_flicker_mode(settings["aeFlickerMode"]) + if "autoExposureMaxUs" in settings and settings["autoExposureMaxUs"]: + if hasattr(camera, "set_auto_exposure_max_us"): + camera.set_auto_exposure_max_us(settings["autoExposureMaxUs"]) + # 更新白平衡设置 / Update white balance settings if "whiteBalanceMode" in settings: mode = settings["whiteBalanceMode"] @@ -977,8 +1007,28 @@ async def update_settings(settings: dict[str, Any]): overrides["digital_gain"] = settings["digitalGain"] if "autoExposure" in settings: overrides["auto_exposure"] = bool(settings["autoExposure"]) + if "noiseReductionMode" in settings: + overrides["noise_reduction_mode"] = settings["noiseReductionMode"] + elif "noiseReduction" in settings: + # 兼容旧控制台数值降噪 / Compat numeric NR control from legacy console. + level = int(settings.get("noiseReduction") or 0) + overrides["noise_reduction_mode"] = ( + "off" if level <= 0 else "fast" if level <= 2 else "high_quality" + ) + if "aeFlickerMode" in settings: + overrides["ae_flicker_mode"] = settings["aeFlickerMode"] + if "autoExposureMaxUs" in settings: + overrides["auto_exposure_max_us"] = settings["autoExposureMaxUs"] if "colorMode" in settings: overrides["color_mode"] = settings["colorMode"] + if "whiteBalanceMode" in settings: + overrides["white_balance_mode"] = settings["whiteBalanceMode"] + overrides["white_balance_gain_r"] = settings.get( + "whiteBalanceGainR", 1.0 + ) + overrides["white_balance_gain_b"] = settings.get( + "whiteBalanceGainB", 1.0 + ) if overrides: get_camera_manager().update_runtime_overrides(overrides) @@ -1062,6 +1112,13 @@ async def set_white_balance(mode: str, gain_r: float = 1.0, gain_b: float = 1.0) raise Exception("相机未初始化") if camera.set_white_balance(mode, gain_r, gain_b): + get_camera_manager().update_runtime_overrides( + { + "white_balance_mode": mode, + "white_balance_gain_r": float(gain_r), + "white_balance_gain_b": float(gain_b), + } + ) return { "success": True, **i18n_payload( @@ -1101,6 +1158,24 @@ async def set_night_mode(enabled: bool): raise Exception("相机未初始化") if camera.set_night_mode(enabled): + overrides = {"night_mode": bool(enabled)} + if enabled: + overrides.update( + { + "white_balance_mode": "night", + "white_balance_gain_r": 1.1, + "white_balance_gain_b": 0.9, + } + ) + else: + overrides.update( + { + "white_balance_mode": "auto", + "white_balance_gain_r": 1.0, + "white_balance_gain_b": 1.0, + } + ) + get_camera_manager().update_runtime_overrides(overrides) mode_text = "启用" if enabled else "关闭" return { "success": True, diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index abd80e7..9295ad9 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -19,6 +19,11 @@ class CameraSettings(BaseModel): saturation: Optional[float] = 1.0 # 饱和度 / saturation sharpness: Optional[float] = 1.0 # 锐度 / sharpness noiseReduction: Optional[int] = 0 # 降噪级别 (0-4) / Noise reduction level (0-4) + noiseReductionMode: Optional[str] = None # 语义降噪模式 / Semantic NR mode + aeFlickerMode: Optional[str] = None # AE 防闪烁 / AE flicker mode + autoExposureMaxUs: Optional[int] = ( + None # 自动曝光最长帧周期 / Max auto-exposure frame duration + ) whiteBalanceMode: Optional[str] = "auto" # 白平衡模式 / white balance mode whiteBalanceGainR: Optional[float] = 1.0 # 白平衡红色增益 / white balance red gain whiteBalanceGainB: Optional[float] = 1.0 # 白平衡蓝色增益 / white balance blue gain @@ -191,6 +196,50 @@ def filtsize_must_be_odd(cls, v: Optional[int]) -> Optional[int]: return v +class SolveObserverContext(BaseModel): + """Observer context for sensor-assisted solve / 传感器辅助解算的观测者上下文。""" + + model_config = ConfigDict(extra="forbid") + + latitude_deg: Optional[float] = Field(default=None, ge=-90.0, le=90.0) + longitude_deg: Optional[float] = Field(default=None, ge=-180.0, le=180.0) + altitude_m: Optional[float] = None + time_utc: Optional[str] = None + source: Optional[str] = None + + +class SolveOrientationContext(BaseModel): + """Orientation context for sensor-assisted solve / 传感器辅助解算的指向上下文。""" + + model_config = ConfigDict(extra="forbid") + + azimuth_deg: Optional[float] = Field(default=None, ge=0.0, le=360.0) + altitude_deg: Optional[float] = Field(default=None, ge=-90.0, le=90.0) + heading_deg: Optional[float] = Field(default=None, ge=0.0, le=360.0) + source: Optional[str] = None + + +class SolveContextQuality(BaseModel): + """Validity flags for sensor-assisted solve / 传感器辅助解算的有效性标记。""" + + model_config = ConfigDict(extra="forbid") + + gps_valid: bool = False + time_valid: bool = False + heading_valid: bool = False + mount_valid: bool = False + + +class SolveContextPayload(BaseModel): + """Optional sensor context from ZenitAPA / ZenitAPA 提供的可选传感器上下文。""" + + model_config = ConfigDict(extra="forbid") + + observer: Optional[SolveObserverContext] = None + orientation: Optional[SolveOrientationContext] = None + quality: Optional[SolveContextQuality] = None + + class AnalysisSolveImageRequest(BaseModel): """单图解算请求(JSON body)/ Single-image plate solve request.""" @@ -202,6 +251,7 @@ class AnalysisSolveImageRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = None + solve_context: Optional[SolveContextPayload] = None solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None centroid: Optional[CentroidParamsPayload] = None max_image_side: Optional[int] = None @@ -215,6 +265,15 @@ class AnalysisSolveImageRequest(BaseModel): le=5, description="1=mild … 5=aggressive dense+collinear rejection", ) + # 叠加与引导选项(可选,未提供则使用后端默认)/ Optional overlay & guidance options + overlay_topn_count: Optional[int] = Field( + default=None, + description="自动标注的星点数量上限(Top-N),未填用服务器默认 / Max number of stars to label (Top-N); server default if omitted", + ) + enable_polar_guide: Optional[bool] = Field( + default=None, + description="是否计算极轴引导信息;未填用服务器默认 / Whether to compute polar guide info; server default if omitted", + ) class AnalysisExtractPreviewRequest(BaseModel): @@ -353,6 +412,7 @@ class AnalysisSolveVideoFrameRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = None + solve_context: Optional[SolveContextPayload] = None solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None centroid: Optional[CentroidParamsPayload] = None max_image_side: Optional[int] = None @@ -407,6 +467,7 @@ class CoreStartAnalysisRequest(BaseModel): fov_estimate: Optional[float] = None fov_max_error: Optional[float] = None solve_timeout_ms: Optional[int] = Field(default=None, ge=200, le=120000) + solve_context: Optional[SolveContextPayload] = None class CoreAnalysisControlResponse(BaseModel): @@ -435,6 +496,7 @@ class CoreSystemStatusResponse(BaseModel): success: bool health: str + health_reasons: list[str] = Field(default_factory=list) version: str capabilities: dict[str, bool] system: dict[str, Any] @@ -492,6 +554,7 @@ class CoreCameraStatusResponse(BaseModel): streaming: bool recording: bool info: dict[str, Any] = Field(default_factory=dict) + ambient_hint: dict[str, Any] = Field(default_factory=dict) runtime_overrides: dict[str, Any] = Field(default_factory=dict) error: Optional[str] = None @@ -504,6 +567,31 @@ class CoreStreamStatusResponse(BaseModel): active_clients: int frame_fetch_timeout_ms: int target_preview_fps: int + sensor_target_fps: float = 0.0 + preview_target_fps: int = 0 + actual_capture_fps: float = 0.0 + actual_preview_fps: float = 0.0 + actual_exposure_us: int = 0 + frame_duration_us: int = 0 + preview_consumers: int = 0 + analysis_consumers: int = 0 + recording_consumers: int = 0 + jpeg_average_encode_ms: float = 0.0 + jpeg_cached_bytes: int = 0 + preview_encoder: str = "" + jpeg_encode_failures: int = 0 + jpeg_source_format: str = "" + camera_driver: str = "" + camera_backend: str = "" + lores_enabled: bool = False + lores_available: bool = False + lores_width: int = 0 + lores_height: int = 0 + lores_format: str = "" + throttle_reason: Optional[str] = None + process_rss_kb: int = 0 + process_swap_kb: int = 0 + cma_free_kb: int = 0 class CoreVideoFileEntry(BaseModel): diff --git a/ogscope/web/api/system/config_files.py b/ogscope/web/api/system/config_files.py new file mode 100644 index 0000000..6818c7e --- /dev/null +++ b/ogscope/web/api/system/config_files.py @@ -0,0 +1,107 @@ +"""Web 配置 env 文件读写辅助 / Helpers for Web-managed env config files.""" + +from __future__ import annotations + +import grp +import os +import subprocess +from pathlib import Path + +CONFIG_WRITE_SCRIPT = Path("/usr/local/bin/ogscope-config-write") +CONFIG_SUDOERS = Path("/etc/sudoers.d/ogscope-config") +CONFIG_FILE_MODE = "640" + + +def config_file_group(path: Path) -> str: + """读取目标文件属组,供 chown 使用 / Group name for chown on target file.""" + if path.exists(): + try: + return grp.getgrgid(path.stat().st_gid).gr_name + except KeyError: + pass + return os.environ.get("USER", "ogscope") + + +def config_write_access() -> dict[str, bool]: + """评估 sudo 写入能力 / Assess sudo-backed config write access.""" + via_sudo = CONFIG_WRITE_SCRIPT.is_file() and CONFIG_SUDOERS.is_file() + return { + "writable_via_sudo": via_sudo, + } + + +def read_config_file_payload(path: Path) -> dict: + """读取配置文件内容与写入能力 / Read config file and write capability flags.""" + exists = path.exists() + access = config_write_access() + if not exists: + parent_writable = os.access(path.parent, os.W_OK) + return { + "path": str(path), + "exists": False, + "writable": parent_writable or access["writable_via_sudo"], + **access, + "content": "", + "error": "file not found", + } + try: + content = path.read_text(encoding="utf-8") + direct = os.access(path, os.W_OK) + writable = direct or access["writable_via_sudo"] + return { + "path": str(path), + "exists": True, + "writable": writable, + "writable_direct": direct, + "writable_via_sudo": access["writable_via_sudo"], + "content": content, + "error": None, + } + except OSError as exc: + return { + "path": str(path), + "exists": True, + "writable": access["writable_via_sudo"], + **access, + "content": "", + "error": str(exc), + } + + +def write_config_file(path: Path, content: str) -> None: + """写入 env 配置文件(必要时 sudo)/ Write env config, using sudo helper when needed.""" + path.parent.mkdir(parents=True, exist_ok=True) + try: + path.write_text(content, encoding="utf-8") + return + except OSError: + pass + + if not CONFIG_WRITE_SCRIPT.is_file(): + raise RuntimeError( + "failed to write config file; install ogscope-config-write via install.sh " + "or ogscope-network-init.sh ensure-config" + ) + + group = config_file_group(path) + proc = subprocess.run( + [ + "sudo", + "-n", + str(CONFIG_WRITE_SCRIPT), + str(path), + CONFIG_FILE_MODE, + group, + ], + input=content, + text=True, + capture_output=True, + check=False, + ) + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "").strip() + raise RuntimeError( + "failed to write config file via sudo; run " + "sudo ./scripts/ogscope-network-init.sh ensure-config " + f"({detail})" + ) diff --git a/ogscope/web/api/system/routes.py b/ogscope/web/api/system/routes.py index 9513f9e..278513e 100644 --- a/ogscope/web/api/system/routes.py +++ b/ogscope/web/api/system/routes.py @@ -3,8 +3,6 @@ """ from pathlib import Path -import os -import subprocess from fastapi import APIRouter, HTTPException from pydantic import BaseModel, Field @@ -14,6 +12,10 @@ from ogscope.domain.system.services import system_info_service from ogscope.platform.hardware_plane.runtime import get_hardware_plane_client from ogscope.web.api.models.schemas import SystemInfo +from ogscope.web.api.system.config_files import ( + read_config_file_payload, + write_config_file, +) router = APIRouter() @@ -59,64 +61,14 @@ def _validate_env_content(content: str) -> None: def _read_config_file(path: Path) -> dict: - exists = path.exists() - writable = os.access(path if exists else path.parent, os.W_OK) - if not exists: - return { - "path": str(path), - "exists": False, - "writable": writable, - "content": "", - "error": "file not found", - } - try: - content = path.read_text(encoding="utf-8") - return { - "path": str(path), - "exists": True, - "writable": writable, - "content": content, - "error": None, - } - except OSError as exc: - return { - "path": str(path), - "exists": True, - "writable": writable, - "content": "", - "error": str(exc), - } + return read_config_file_payload(path) def _write_config_file(path: Path, content: str) -> None: - path.parent.mkdir(parents=True, exist_ok=True) try: - path.write_text(content, encoding="utf-8") - return - except OSError: - pass - - proc = subprocess.run( - ["sudo", "-n", "tee", str(path)], - input=content, - text=True, - capture_output=True, - check=False, - ) - if proc.returncode != 0: - raise HTTPException( - status_code=500, - detail=( - "failed to write config file; grant write permission " - "or allow sudo tee without password" - ), - ) - subprocess.run( - ["sudo", "-n", "chmod", "640", str(path)], - capture_output=True, - text=True, - check=False, - ) + write_config_file(path, content) + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.get("/system/info", response_model=SystemInfo) diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 6f5c290..70224fe 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -101,31 +101,16 @@ async def _warm_solver() -> None: "相机自动启动失败,将按需延迟启动 / Camera auto-start failed, fallback to lazy start: {}", e, ) - phase_elapsed_ms = int((asyncio.get_running_loop().time() - phase_p0_started) * 1000) + phase_elapsed_ms = int( + (asyncio.get_running_loop().time() - phase_p0_started) * 1000 + ) logger.info("启动阶段完成 / Startup phases ready in {} ms", phase_elapsed_ms) - try: - from ogscope.platform.hardware.wifi_emergency_gpio import ( - wifi_emergency_gpio_monitor, - ) - - wifi_emergency_gpio_monitor.start() - except Exception as e: - logger.warning("应急 GPIO 启动失败 / Emergency GPIO start failed: {}", e) - yield # 关闭时执行 / Execute on shutdown logger.info("清理资源...") shutdown_started = asyncio.get_running_loop().time() - try: - from ogscope.platform.hardware.wifi_emergency_gpio import ( - wifi_emergency_gpio_monitor, - ) - - wifi_emergency_gpio_monitor.stop() - except Exception as e: - logger.warning("应急 GPIO 停止异常 / Emergency GPIO stop error: {}", e) try: from ogscope.utils.environment import should_use_simulation_mode @@ -141,8 +126,12 @@ async def _warm_solver() -> None: logger.warning( "硬件平面停止超时或异常 / Hardware plane stop timeout or error: {}", e ) - shutdown_elapsed_ms = int((asyncio.get_running_loop().time() - shutdown_started) * 1000) - logger.info("退出阶段完成 / Shutdown cleanup finished in {} ms", shutdown_elapsed_ms) + shutdown_elapsed_ms = int( + (asyncio.get_running_loop().time() - shutdown_started) * 1000 + ) + logger.info( + "退出阶段完成 / Shutdown cleanup finished in {} ms", shutdown_elapsed_ms + ) # API 文档分组标签 / API documentation group tags @@ -268,6 +257,7 @@ async def _guard_subordinate_dev_routes(request: Request, call_next): ) return await call_next(request) + # 挂载静态文件 / Mount static files if bool(hardware_profile["enable_ui"]) and settings.static_dir.exists(): app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static") @@ -438,7 +428,9 @@ def _filtered_openapi_schema(*, mode: str) -> dict: filtered_paths: dict[str, dict] = {} if mode == "core": filtered_paths = { - path: data for path, data in paths.items() if path.startswith("/api/core/v1/") + path: data + for path, data in paths.items() + if path.startswith("/api/core/v1/") } elif mode == "dev": filtered_paths = { diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index e355fb8..9b94944 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -6,12 +6,20 @@ import asyncio import logging +import os import time +from collections import deque +from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from threading import Lock from typing import Any, Callable from ogscope.config import get_settings +from ogscope.domain.camera.encoding import ( + EncodedImage, + OpenCVEncoder, + create_preview_encoder, +) @dataclass(slots=True) @@ -35,7 +43,9 @@ def __init__(self) -> None: self._read_lock = Lock() self._frame_lock = Lock() self._grabber_task: asyncio.Task | None = None + self._idle_shutdown_task: asyncio.Task | None = None self._frame_id = 0 + self._capture_sequence = 0 self._latest_raw = None self._latest_jpeg: bytes | None = None self._latest_ts = 0.0 @@ -44,12 +54,32 @@ def __init__(self) -> None: self._runtime_overrides: dict[str, Any] = {} settings = get_settings() self._jpeg_quality = int(settings.preview_jpeg_quality) + self._preview_encoder = create_preview_encoder( + getattr(settings, "preview_encoder", "auto") + ) + self._last_jpeg_encoder = getattr(self._preview_encoder, "name", "opencv") + self._last_jpeg_source_format = "RGB888" + self._jpeg_encode_failures = 0 self._target_fps = max(1, int(settings.shared_preview_fps)) self._probe_timeout_sec = max(0.5, float(settings.camera_probe_timeout_sec)) + self._stale_timeout_sec = max( + 0.5, float(settings.camera_frame_stale_timeout_sec) + ) + self._idle_shutdown_sec = max(0.0, float(settings.camera_idle_shutdown_sec)) self._max_grab_failures = max(1, int(settings.camera_grab_failures_offline)) self._health_error: str | None = None self._consecutive_grab_failures = 0 self._stream_started_at = 0.0 + self._last_capture_success_mono = 0.0 + self._preview_consumers = 0 + self._analysis_consumers = 0 + self._recording_consumers = 0 + self._capture_timestamps: deque[float] = deque(maxlen=120) + self._jpeg_timestamps: deque[float] = deque(maxlen=120) + self._jpeg_encode_ms: deque[float] = deque(maxlen=60) + self._jpeg_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="ogscope-jpeg" + ) # 是否常驻 raw 帧缓存;默认关闭以降低内存占用(分析路径可同步抓帧) # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). self._keep_raw_cache = bool(settings.keep_raw_cache) @@ -60,6 +90,11 @@ def preview_jpeg_quality(self) -> int: """共享抓帧 JPEG 质量(与缓存一致)/ Shared grabber JPEG quality (matches cache).""" return int(self._jpeg_quality) + @property + def preview_target_fps(self) -> int: + """共享预览目标帧率 / Shared preview target FPS.""" + return int(self._target_fps) + def _build_base_config(self) -> dict[str, Any]: from ogscope.config import get_settings @@ -74,19 +109,36 @@ def _build_base_config(self) -> dict[str, Any]: "auto_exposure": True, "ae_polar_preset": settings.camera_ae_polar_preset, "ae_exposure_value": settings.camera_ae_exposure_value, + "auto_exposure_max_us": getattr( + settings, "camera_auto_exposure_max_us", 2_000_000 + ), + "ae_flicker_mode": getattr(settings, "camera_ae_flicker_mode", "off"), + "noise_reduction_mode": getattr( + settings, "camera_noise_reduction_mode", "fast" + ), + "lores_enabled": bool(getattr(settings, "camera_lores_enabled", True)), + "lores_width": int(getattr(settings, "camera_lores_width", 320)), + "lores_height": int(getattr(settings, "camera_lores_height", 240)), + "lores_format": getattr(settings, "camera_lores_format", "YUV420"), "rotation": 180, "flip_horizontal": bool(getattr(settings, "camera_flip_horizontal", False)), "flip_vertical": bool(getattr(settings, "camera_flip_vertical", False)), "sampling_mode": getattr(settings, "camera_sampling_mode", "native"), - "noise_reduction": 0, - "white_balance_mode": "auto", - "white_balance_gain_r": 1.0, - "white_balance_gain_b": 1.0, + "noise_reduction": 1, + "white_balance_mode": getattr( + settings, "camera_white_balance_mode", "auto" + ), + "white_balance_gain_r": getattr( + settings, "camera_white_balance_gain_r", 1.0 + ), + "white_balance_gain_b": getattr( + settings, "camera_white_balance_gain_b", 1.0 + ), "contrast": 1.0, "brightness": 0.0, "saturation": 1.0, "sharpness": 1.0, - "night_mode": False, + "night_mode": bool(getattr(settings, "camera_night_mode", False)), "color_mode": "color", } return {**base, **self._runtime_overrides} @@ -100,28 +152,80 @@ def _create_camera_sync(self): return camera return None - def _encode_preview_jpeg_sync(self, frame) -> bytes | None: + def _encode_preview_jpeg_sync(self, frame) -> EncodedImage | None: + source_format = str( + getattr(self._camera, "output_pixel_format", None) + or getattr(self._camera, "pixel_format", None) + or "RGB888" + ) try: - import cv2 - - ok, buf = cv2.imencode( - ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, int(self._jpeg_quality)] + encoded = self._preview_encoder.encode_jpeg( + frame, quality=int(self._jpeg_quality), source_format=source_format + ) + if encoded is not None: + return encoded + except Exception as exc: + self._logger.debug("预览编码失败 / Preview encode failed: %s", exc) + # TurboJPEG 或首选编码器异常时,当前帧回退 OpenCV;下次仍保留首选项便于热安装后生效 + # Fall back to OpenCV for this frame if the preferred encoder fails. + try: + return OpenCVEncoder().encode_jpeg( + frame, quality=int(self._jpeg_quality), source_format=source_format + ) + except Exception as exc: + self._logger.debug( + "OpenCV 回退编码失败 / OpenCV fallback encode failed: %s", exc ) - if not ok: - return None - return buf.tobytes() - except Exception: return None def _read_frame_sync(self): with self._read_lock: if self._camera is None or not getattr(self._camera, "is_capturing", False): return None - return self._camera.get_video_frame() - - async def ensure_started(self) -> None: + frame = self._camera.get_video_frame() + if frame is not None: + now = time.monotonic() + self._capture_sequence += 1 + self._last_capture_success_mono = now + self._capture_timestamps.append(now) + return frame + + def _camera_is_fresh(self) -> bool: + """判断运行中的相机是否仍有新鲜帧 / Check whether a running camera is still fresh.""" + if self._camera is None or not getattr(self._camera, "is_capturing", False): + return False + if self._last_capture_success_mono <= 0: + return False + return ( + time.monotonic() - self._last_capture_success_mono + ) <= self._stale_timeout_sec + + def _has_consumers(self) -> bool: + return ( + self._preview_consumers + + self._analysis_consumers + + self._recording_consumers + ) > 0 + + def _cancel_idle_shutdown(self) -> None: + task = self._idle_shutdown_task + if task is not None and not task.done(): + task.cancel() + self._idle_shutdown_task = None + + async def ensure_started(self, *, start_grabber: bool = False) -> None: """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" + self._cancel_idle_shutdown() + if self._camera_is_fresh(): + if start_grabber: + async with self._control_lock: + await self._ensure_grabber_locked() + return async with self._control_lock: + if self._camera_is_fresh(): + if start_grabber: + await self._ensure_grabber_locked() + return if self._camera is None: self._health_error = None self._camera = await asyncio.to_thread(self._create_camera_sync) @@ -146,10 +250,68 @@ async def ensure_started(self) -> None: raise RuntimeError(self._health_error or "相机无有效帧") self._health_error = None self._consecutive_grab_failures = 0 - await self._ensure_grabber_locked() + if start_grabber: + await self._ensure_grabber_locked() + + async def acquire_preview_consumer(self) -> None: + """注册预览消费者并启动共享编码 / Register a preview consumer.""" + self._preview_consumers += 1 + try: + await self.ensure_started(start_grabber=True) + except Exception: + self._preview_consumers = max(0, self._preview_consumers - 1) + raise + + async def release_preview_consumer(self) -> None: + """释放预览消费者;最后一路离开时停止JPEG流水线 / Release preview consumer.""" + self._preview_consumers = max(0, self._preview_consumers - 1) + if self._preview_consumers == 0: + async with self._control_lock: + await self._stop_grabber_locked() + with self._frame_lock: + self._latest_jpeg = None + self._schedule_idle_shutdown() + + async def acquire_recording_consumer(self) -> None: + """注册录像消费者 / Register a recording consumer.""" + self._recording_consumers += 1 + try: + await self.ensure_started() + except Exception: + self._recording_consumers = max(0, self._recording_consumers - 1) + raise + + async def release_recording_consumer(self) -> None: + """释放录像消费者 / Release a recording consumer.""" + self._recording_consumers = max(0, self._recording_consumers - 1) + self._schedule_idle_shutdown() + + def _schedule_idle_shutdown(self) -> None: + if self._has_consumers(): + return + self._cancel_idle_shutdown() + self._idle_shutdown_task = asyncio.create_task( + self._idle_shutdown_after_delay() + ) + + async def _idle_shutdown_after_delay(self) -> None: + """热驻留结束后释放相机 / Release camera after the warm-idle period.""" + try: + if self._idle_shutdown_sec > 0: + await asyncio.sleep(self._idle_shutdown_sec) + if not self._has_consumers(): + await self.stop() + except asyncio.CancelledError: + raise + finally: + if asyncio.current_task() is self._idle_shutdown_task: + self._idle_shutdown_task = None async def stop(self) -> None: """停止相机采集 / Stop camera capture.""" + current = asyncio.current_task() + if self._idle_shutdown_task is not current: + self._cancel_idle_shutdown() acquired = False try: await asyncio.wait_for(self._control_lock.acquire(), timeout=2.0) @@ -180,6 +342,7 @@ async def stop(self) -> None: "相机关闭超时,继续执行退出流程 / Camera close timed out, continue shutdown" ) self._camera = None + self._last_capture_success_mono = 0.0 with self._frame_lock: self._latest_raw = None self._latest_jpeg = None @@ -310,18 +473,25 @@ async def _stop_grabber_locked(self) -> None: self._grabber_task = None async def _grabber_loop(self) -> None: - interval = 1.0 / float(self._target_fps) loop = asyncio.get_running_loop() try: while True: + interval = 1.0 / float(max(1, self._target_fps)) t0 = time.time() try: frame = await asyncio.to_thread(self._read_frame_sync) if frame is not None: self._consecutive_grab_failures = 0 - jpeg = await loop.run_in_executor( - None, self._encode_preview_jpeg_sync, frame + encode_t0 = time.perf_counter() + encoded = await loop.run_in_executor( + self._jpeg_executor, self._encode_preview_jpeg_sync, frame ) + encode_ms = (time.perf_counter() - encode_t0) * 1000.0 + if encoded is None: + self._jpeg_encode_failures += 1 + await asyncio.sleep(max(0.0, interval - (time.time() - t0))) + continue + jpeg = encoded.data h = int(getattr(frame, "shape", [0, 0])[0] or 0) w = int(getattr(frame, "shape", [0, 0])[1] or 0) with self._frame_lock: @@ -333,6 +503,11 @@ async def _grabber_loop(self) -> None: self._latest_ts = time.time() self._latest_w = w self._latest_h = h + self._last_jpeg_encoder = encoded.encoder + self._last_jpeg_source_format = encoded.source_format + now_mono = time.monotonic() + self._jpeg_timestamps.append(now_mono) + self._jpeg_encode_ms.append(encode_ms) else: self._consecutive_grab_failures += 1 if self._consecutive_grab_failures >= self._max_grab_failures: @@ -416,7 +591,7 @@ async def get_preview_frame( self, since_id: int | None = None, wait_timeout_sec: float = 0.8 ) -> tuple[int, SharedFrame | None]: """读取预览帧;如未更新则返回 304 / Get preview frame; return 304 if unchanged.""" - await self.ensure_started() + await self.ensure_started(start_grabber=True) deadline = time.time() + max(0.0, float(wait_timeout_sec)) while True: with self._frame_lock: @@ -438,27 +613,27 @@ async def get_preview_frame( async def get_raw_frame(self) -> tuple[Any, int, float]: """读取分析帧 / Get frame for analysis.""" - await self.ensure_started() - with self._frame_lock: - if self._latest_raw is not None: - try: - frame = self._latest_raw.copy() - except Exception: - frame = self._latest_raw - return frame, self._frame_id, self._latest_ts - # 无常驻 raw 时同步抓一帧,供解算使用(不写入 _latest_raw,除非开启 keep cache) - # Sync-grab when raw cache is disabled; avoids breaking analysis while saving RAM. - frame = await asyncio.to_thread(self._read_frame_sync) - if frame is None: - raise RuntimeError("无可用视频帧 / No frame available") - with self._frame_lock: - fid = self._frame_id - ts = self._latest_ts + self._analysis_consumers += 1 try: - out = frame.copy() - except Exception: - out = frame - return out, fid, ts + await self.ensure_started() + with self._frame_lock: + if self._latest_raw is not None: + return ( + self._latest_raw.copy(), + self._capture_sequence, + self._latest_ts, + ) + # 无常驻 raw 时同步抓一帧,供解算使用 / Sync-grab without retaining raw. + frame = await asyncio.to_thread(self._read_frame_sync) + if frame is None: + raise RuntimeError("无可用视频帧 / No frame available") + with self._frame_lock: + fid = self._capture_sequence + ts = time.time() + return frame, fid, ts + finally: + self._analysis_consumers = max(0, self._analysis_consumers - 1) + self._schedule_idle_shutdown() async def get_cached_frame_snapshot(self) -> SharedFrame | None: """读取当前缓存帧快照(不触发 ensure)/ Read cached snapshot without ensure.""" @@ -480,19 +655,14 @@ def encode_frame( ) -> bytes | None: """将原始帧编码为图像字节 / Encode raw frame to image bytes.""" try: - import cv2 - if image_format.lower() == "png": - ok, buf = cv2.imencode(".png", raw_frame) - else: - ok, buf = cv2.imencode( - ".jpg", - raw_frame, - [cv2.IMWRITE_JPEG_QUALITY, int(max(10, min(100, quality)))], - ) - if not ok: - return None - return buf.tobytes() + return OpenCVEncoder().encode_png(raw_frame, source_format="RGB888") + encoded = create_preview_encoder("auto").encode_jpeg( + raw_frame, + quality=int(max(10, min(100, quality))), + source_format="RGB888", + ) + return encoded.data if encoded is not None else None except Exception: return None @@ -500,6 +670,101 @@ def update_runtime_overrides(self, updates: dict[str, Any]) -> None: """更新运行时覆盖参数(不落盘)/ Update runtime overrides (memory only).""" self._runtime_overrides.update(updates) + def set_preview_fps(self, fps: int) -> int: + """独立更新共享预览帧率 / Independently update shared preview FPS.""" + self._target_fps = max(1, min(30, int(fps))) + return self._target_fps + + @staticmethod + def _rate(values: deque[float], window_sec: float = 3.0) -> float: + now = time.monotonic() + recent = [v for v in values if now - v <= window_sec] + if len(recent) < 2: + return 0.0 + span = recent[-1] - recent[0] + return 0.0 if span <= 0 else (len(recent) - 1) / span + + async def stream_metrics(self) -> dict[str, Any]: + """返回预览运行指标 / Return preview runtime metrics.""" + cam = self._camera + info: dict[str, Any] = {} + if cam is not None: + info = await asyncio.to_thread(cam.get_camera_info) + actual_capture_fps = self._rate(self._capture_timestamps) + actual_preview_fps = self._rate(self._jpeg_timestamps) + sensor_target_fps = float(info.get("fps", 0) or 0) + exposure_us = int( + info.get("actual_exposure_us", info.get("exposure_us", 0)) or 0 + ) + frame_duration_us = int(info.get("frame_duration_us", 0) or 0) + throttle_reason = None + if ( + bool(info.get("auto_exposure")) + and sensor_target_fps > 0 + and actual_capture_fps > 0 + and actual_capture_fps < sensor_target_fps * 0.75 + ): + throttle_reason = "auto_exposure_long" + memory = self._memory_metrics() + return { + "sensor_target_fps": sensor_target_fps, + "preview_target_fps": int(self._target_fps), + "actual_capture_fps": round(actual_capture_fps, 2), + "actual_preview_fps": round(actual_preview_fps, 2), + "actual_exposure_us": exposure_us, + "frame_duration_us": frame_duration_us, + "preview_consumers": int(self._preview_consumers), + "analysis_consumers": int(self._analysis_consumers), + "recording_consumers": int(self._recording_consumers), + "jpeg_average_encode_ms": ( + round(sum(self._jpeg_encode_ms) / len(self._jpeg_encode_ms), 2) + if self._jpeg_encode_ms + else 0.0 + ), + "jpeg_cached_bytes": len(self._latest_jpeg or b""), + "preview_encoder": self._last_jpeg_encoder, + "jpeg_encode_failures": int(self._jpeg_encode_failures), + "jpeg_source_format": self._last_jpeg_source_format, + "camera_driver": str(info.get("driver", "")), + "camera_backend": str(info.get("backend", "")), + "lores_enabled": bool(info.get("lores_enabled", False)), + "lores_available": bool(info.get("lores_available", False)), + "lores_width": int(info.get("lores_width", 0) or 0), + "lores_height": int(info.get("lores_height", 0) or 0), + "lores_format": str(info.get("lores_format", "")), + "throttle_reason": throttle_reason, + **memory, + } + + @staticmethod + def _memory_metrics() -> dict[str, int]: + """读取轻量进程与CMA指标 / Read lightweight process and CMA metrics.""" + rss_kb = 0 + swap_kb = 0 + cma_free_kb = 0 + try: + with open(f"/proc/{os.getpid()}/status", encoding="utf-8") as status_file: + for line in status_file: + if line.startswith("VmRSS:"): + rss_kb = int(line.split()[1]) + elif line.startswith("VmSwap:"): + swap_kb = int(line.split()[1]) + except (OSError, ValueError, IndexError): + pass + try: + with open("/proc/meminfo", encoding="utf-8") as meminfo_file: + for line in meminfo_file: + if line.startswith("CmaFree:"): + cma_free_kb = int(line.split()[1]) + break + except (OSError, ValueError, IndexError): + pass + return { + "process_rss_kb": rss_kb, + "process_swap_kb": swap_kb, + "cma_free_kb": cma_free_kb, + } + def get_runtime_overrides(self) -> dict[str, Any]: """读取运行时覆盖参数 / Read runtime overrides.""" return dict(self._runtime_overrides) diff --git a/ogscope/web/mjpeg_stream_limiter.py b/ogscope/web/mjpeg_stream_limiter.py index 0c2371b..44bd961 100644 --- a/ogscope/web/mjpeg_stream_limiter.py +++ b/ogscope/web/mjpeg_stream_limiter.py @@ -2,6 +2,9 @@ MJPEG 长连接并发限制 / Concurrent MJPEG stream limiter """ -from ogscope.domain.camera.stream_limiter import MjpegStreamLimiter, get_mjpeg_stream_limiter +from ogscope.domain.camera.stream_limiter import ( + MjpegStreamLimiter, + get_mjpeg_stream_limiter, +) __all__ = ["MjpegStreamLimiter", "get_mjpeg_stream_limiter"] diff --git a/poetry.lock b/poetry.lock index adfc574..85a038d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1459,6 +1459,23 @@ files = [ [package.extras] dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] +[[package]] +name = "pyturbojpeg" +version = "1.8.3" +description = "A Python wrapper of libjpeg-turbo for decoding and encoding JPEG image." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyturbojpeg-1.8.3.tar.gz", hash = "sha256:c131591a3990cc57f45a8b2705d6261c25df913a19b1fe88de5e911dbe04a1d4"}, +] + +[package.dependencies] +numpy = "*" + +[package.extras] +test = ["pytest (>=7.0.0)"] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2174,4 +2191,4 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "6ab4946a58510dbf18d12530214a58a8d388fcbea68bee380eedda563b09f7a6" +content-hash = "3594144b7b828c0797a3737cbaf72c6acecee25d7d11e66c7c89a81e86640367" diff --git a/pyproject.toml b/pyproject.toml index 4c4e594..54914b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ numpy = ">=2,<3" opencv-python-headless = ">=4.12,<5" pillow = ">=10,<13" scipy = ">=1.10,<1.17" +PyTurboJPEG = ">=1.7,<2" # 相机支持 (Raspberry Pi MIPI) # picamera2 = "^0.3.0" # 树莓派 MIPI 相机支持 (仅Linux) @@ -176,4 +177,3 @@ exclude_lines = [ "class .*\\bProtocol\\):", "@(abc\\.)?abstractmethod", ] - diff --git a/scripts/board-update.sh b/scripts/board-update.sh index 93c53b1..6e05230 100755 --- a/scripts/board-update.sh +++ b/scripts/board-update.sh @@ -10,7 +10,9 @@ # OGSCOPE_SKIP_PLATE_DB=1 — 不复制 default_database.npz / Skip Tetra3 pattern DB copy # OGSCOPE_FORCE_PLATE_DB=1 — 覆盖已存在的 data/plate_solve/default_database.npz / Overwrite pattern DB # OGSCOPE_SKIP_NETWORK_SYNC=1 — 不同步 WiFi 切换脚本与 ensure-systemd(免密 sudo 不可用时可设)/ Skip WiFi script + ensure-systemd -# OGSCOPE_CAMERA=imx327|skip — 非交互指定摄像头 boot 配置 / Boot camera preset (non-interactive) +# OGSCOPE_CAMERA=imx327|skip — 指定摄像头 boot 配置 / Boot camera preset +# OGSCOPE_CAMERA_DEFAULT=imx327|skip — 无 TTY/非交互默认值,默认 imx327 / Default for non-TTY/non-interactive; default imx327 +# OGSCOPE_SKIP_CAMERA_STACK=1 — 不补装 Picamera2/libcamera 运行栈 / Skip Picamera2/libcamera runtime repair # OGSCOPE_SKIP_BOOT_CAMERA=1 — 不询问、不写入 /boot 摄像头配置 / Skip boot camera prompt and changes # OGSCOPE_SKIP_BOOT_I2C=1 — 不写入 /boot 中 dtparam=i2c_arm=on(仍会安装 i2c-tools、仍将用户加入 i2c 组)/ Skip I2C boot dtparam; still installs i2c-tools and adds user to i2c group # OGSCOPE_SKIP_JOURNALD_PERSISTENT=1 — 不同步 journald 持久化配置 / Skip journald persistent drop-in @@ -103,8 +105,23 @@ if ! ogscope_verify_numpy_scipy; then fi echo "✅ numpy/scipy 已就绪 / numpy & scipy OK" +# TurboJPEG 是预览 JPEG 加速路径;若 Poetry 未补齐,增量更新时兜底安装。 +# TurboJPEG accelerates preview JPEG encoding; board-update repairs missing binding/lib. +if ! ogscope_verify_turbojpeg; then + echo "⚠️ TurboJPEG 不可用,尝试补装 libturbojpeg0 + PyTurboJPEG / TurboJPEG unavailable; installing fallback deps" + sudo apt update -qq + sudo apt install -y libturbojpeg0 + poetry run pip install --no-cache-dir "PyTurboJPEG>=1.7,<2" +fi +if ogscope_verify_turbojpeg; then + echo "✅ TurboJPEG 编码加速已就绪 / TurboJPEG encoder ready" +else + echo "⚠️ TurboJPEG 仍不可用,将自动回退 OpenCV / TurboJPEG still unavailable; OpenCV fallback will be used" +fi + echo "📦 I²C 主机依赖(与 install.sh 对齐)/ I2C host setup (aligned with install.sh)..." sudo apt update -qq +ogscope_install_camera_stack_if_needed ogscope_i2c_host_setup_full 1 VENV_PYTHON="$(poetry env info --path)/bin/python" @@ -136,6 +153,8 @@ fi sudo chown "root:${USER}" "${OGSCOPE_ENV_FILE}" 2>/dev/null || true sudo chmod 640 "${OGSCOPE_ENV_FILE}" 2>/dev/null || true +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + chmod +x "${PROJECT_DIR}/scripts/ogscope-network-boot.sh" 2>/dev/null || true ogscope_sync_network_boot_unit_if_needed "${PROJECT_DIR}" diff --git a/scripts/boot-config-camera.sh b/scripts/boot-config-camera.sh index 526d689..6fdcc94 100644 --- a/scripts/boot-config-camera.sh +++ b/scripts/boot-config-camera.sh @@ -2,9 +2,70 @@ # 由 install.sh、board-update.sh 用 `source` 加载 / Sourced by install.sh and board-update.sh # # 环境变量 / Environment: -# OGSCOPE_CAMERA=imx327|skip — 非交互时指定摄像头型号或跳过 / Preset camera model or skip (non-interactive) +# OGSCOPE_CAMERA=imx327|skip — 指定摄像头型号或跳过 / Preset camera model or skip +# OGSCOPE_CAMERA_DEFAULT=imx327|skip — 无 TTY/非交互默认值,默认 imx327 / Default for non-TTY/non-interactive; default imx327 +# OGSCOPE_SKIP_CAMERA_STACK=1 — 不补装 Picamera2/libcamera 运行栈 / Skip Picamera2/libcamera runtime repair # OGSCOPE_SKIP_BOOT_CAMERA=1 — 不询问、不修改 /boot 配置 / Do not prompt or modify boot config -# OGSCOPE_NONINTERACTIVE=1 — 无 TTY 时不提示;未设 OGSCOPE_CAMERA 时等同 skip / No prompt; default skip without OGSCOPE_CAMERA +# OGSCOPE_NONINTERACTIVE=1 — 不提示;未设 OGSCOPE_CAMERA 时使用 OGSCOPE_CAMERA_DEFAULT / No prompt; use OGSCOPE_CAMERA_DEFAULT without OGSCOPE_CAMERA + +ogscope_camera_apt_install_if_available() { + local pkg="$1" + local label="$2" + if ! apt-cache show "${pkg}" >/dev/null 2>&1; then + return 1 + fi + echo "📦 安装 ${label}: ${pkg} / Installing ${label}: ${pkg}" + if sudo apt install -y "${pkg}"; then + return 0 + fi + echo "⚠️ ${pkg} 安装失败,继续后续步骤 / ${pkg} install failed; continuing" >&2 + return 2 +} + +# 补齐树莓派 CSI 相机运行栈;增量更新也调用,避免只重装时才修复。 +# Repair Raspberry Pi CSI camera runtime; board-update calls this too, not only reinstall. +ogscope_install_camera_stack_if_needed() { + if [ "${OGSCOPE_SKIP_CAMERA_STACK:-}" = "1" ]; then + echo "⏭️ 跳过相机运行栈补装(OGSCOPE_SKIP_CAMERA_STACK=1)/ Skipping camera stack repair" + return 0 + fi + + if ! command -v apt-cache >/dev/null 2>&1; then + echo "ℹ️ 未找到 apt-cache,跳过相机运行栈补装 / apt-cache not found; skipped camera stack repair" + return 0 + fi + + if python3 -c 'from picamera2 import Picamera2' >/dev/null 2>&1; then + echo "✅ Picamera2 已可导入 / Picamera2 import OK" + else + if ogscope_camera_apt_install_if_available "python3-picamera2" "Picamera2"; then + : + else + _picamera_install_status=$? + if [ "${_picamera_install_status}" -eq 1 ]; then + echo "ℹ️ 未找到 python3-picamera2 软件包,请按板卡文档安装相机栈 / No python3-picamera2 package; install camera stack per board docs" + fi + fi + if python3 -c 'from picamera2 import Picamera2' >/dev/null 2>&1; then + echo "✅ Picamera2 补装完成 / Picamera2 repaired" + else + echo "⚠️ Picamera2 仍不可导入;若相机不可用请检查 apt 源与板卡相机栈 / Picamera2 still unavailable; check apt source and board camera stack" + fi + fi + + if command -v rpicam-hello >/dev/null 2>&1 || command -v libcamera-hello >/dev/null 2>&1; then + echo "✅ libcamera/rpicam 工具已存在 / libcamera/rpicam tools found" + return 0 + fi + + local pkg + for pkg in rpicam-apps-core rpicam-apps libcamera-apps; do + if ogscope_camera_apt_install_if_available "${pkg}" "libcamera/rpicam 工具 / tools"; then + return 0 + fi + done + echo "ℹ️ 未找到 rpicam/libcamera apps 软件包;Picamera2 可用时 OGScope 仍可运行 / No rpicam/libcamera apps package; OGScope can still run if Picamera2 works" +} # 返回可写的 config.txt 路径(Bookworm 多为 /boot/firmware/config.txt)/ Resolve config.txt path ogscope_boot_config_path() { @@ -71,7 +132,10 @@ ogscope_boot_config_apply_imx327() { END { exit (inserted ? 0 : 1) } ' "${cfg}" > "${tmp}"; then sudo cp -a "${cfg}" "${cfg}.bak.ogscope.$(date +%s)" - sudo mv "${tmp}" "${cfg}" + # /boot/firmware 常见为 FAT 分区,mv 会尝试保留所有权并打印误导性警告;用 cp 覆盖内容。 + # /boot/firmware is often FAT; mv may warn about ownership preservation, so copy contents instead. + sudo cp "${tmp}" "${cfg}" + rm -f "${tmp}" sudo chown root:root "${cfg}" 2>/dev/null || true sudo chmod 644 "${cfg}" 2>/dev/null || true return 0 @@ -130,13 +194,19 @@ ogscope_resolve_camera_choice() { return 0 fi - if [ "${OGSCOPE_NONINTERACTIVE:-}" = "1" ]; then - echo "skip" - return 0 - fi - - if [ ! -t 0 ]; then - echo "skip" + if [ "${OGSCOPE_NONINTERACTIVE:-}" = "1" ] || [ ! -t 0 ]; then + case "${OGSCOPE_CAMERA_DEFAULT:-imx327}" in + imx327 | IMX327) + echo "imx327" + ;; + skip | none | off | "") + echo "skip" + ;; + *) + echo "⚠️ 未知 OGSCOPE_CAMERA_DEFAULT=${OGSCOPE_CAMERA_DEFAULT},按 skip 处理 / Unknown OGSCOPE_CAMERA_DEFAULT; using skip" >&2 + echo "skip" + ;; + esac return 0 fi diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 4c72749..f733666 100644 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -44,6 +44,9 @@ rsync -a --delete \ --exclude "__pycache__/" \ --exclude ".pytest_cache/" \ --exclude "web/spa/node_modules/" \ + --exclude "uploads/" \ + --exclude "logs/" \ + --exclude "data/" \ "${SOURCE_DIR}/" "${DEPLOY_DIR}/" INSTALL_SCRIPT="${DEPLOY_DIR}/scripts/install.sh" diff --git a/scripts/diagnose_camera.py b/scripts/diagnose_camera.py index bc7c8a7..0b1d027 100644 --- a/scripts/diagnose_camera.py +++ b/scripts/diagnose_camera.py @@ -4,11 +4,13 @@ 用于检查相机初始化、启动和运行状态 """ import asyncio -import httpx +import importlib.util import json import sys from pathlib import Path +import httpx + BASE_URL = "http://localhost:8000/api/debug/camera" @@ -76,7 +78,7 @@ async def test_preview(client): content_type = response.headers.get("content-type", "") content_length = len(response.content) - print(f"✅ 预览响应:") + print("✅ 预览响应:") print(f" - Content-Type: {content_type}") print(f" - Content-Length: {content_length} bytes") @@ -137,31 +139,25 @@ async def check_system_dependencies(): print("\n🔧 检查系统依赖...") # 检查 Picamera2 / Check Picamera2 - try: - import picamera2 - + if importlib.util.find_spec("picamera2") is not None: print("✅ Picamera2 已安装") - except ImportError: + else: print("❌ Picamera2 未安装") print(" 请运行: sudo apt install python3-picamera2") return False # 检查 OpenCV / Check OpenCV - try: - import cv2 - + if importlib.util.find_spec("cv2") is not None: print("✅ OpenCV 已安装") - except ImportError: + else: print("⚠️ OpenCV 未安装 (直方图功能需要)") print(" 请运行: sudo apt install python3-opencv") print(" 或: pip install opencv-python-headless") # 检查 NumPy / Check NumPy - try: - import numpy - + if importlib.util.find_spec("numpy") is not None: print("✅ NumPy 已安装") - except ImportError: + else: print("❌ NumPy 未安装") return False diff --git a/scripts/install-min.sh b/scripts/install-min.sh index 8fe88d3..4236fc9 100644 --- a/scripts/install-min.sh +++ b/scripts/install-min.sh @@ -60,7 +60,8 @@ if [ "${OGSCOPE_MIN_SKIP_APT:-0}" != "1" ]; then python3-dev \ git \ curl \ - build-essential + build-essential \ + libturbojpeg0 fi if ! command -v poetry >/dev/null 2>&1; then @@ -91,6 +92,17 @@ fi poetry "${INSTALL_ARGS[@]}" +if ! ogscope_verify_turbojpeg; then + echo "⚠️ TurboJPEG 不可用,尝试补装 libturbojpeg0 + PyTurboJPEG / TurboJPEG unavailable; installing fallback deps" + sudo apt install -y libturbojpeg0 + poetry run pip install --no-cache-dir "PyTurboJPEG>=1.7,<2" +fi +if ogscope_verify_turbojpeg; then + echo "✅ TurboJPEG 编码加速已就绪 / TurboJPEG encoder ready" +else + echo "⚠️ TurboJPEG 仍不可用,将自动回退 OpenCV / TurboJPEG still unavailable; OpenCV fallback will be used" +fi + VENV_PATH="$(poetry env info --path)" VENV_PYTHON="${VENV_PATH}/bin/python" if [ ! -x "${VENV_PYTHON}" ]; then @@ -121,6 +133,8 @@ fi sudo chown "root:${USER}" "${OGSCOPE_ENV_FILE}" 2>/dev/null || true sudo chmod 640 "${OGSCOPE_ENV_FILE}" 2>/dev/null || true +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + echo "⚙️ 写入 systemd 服务 / Writing systemd service..." sudo tee "${SERVICE_PATH}" >/dev/null </dev/null 2>&1; then - echo "📦 安装 python3-picamera2..." - sudo apt install -y python3-picamera2 || echo "⚠️ picamera2 安装跳过 / picamera2 install skipped" -else - echo "ℹ️ 未找到 python3-picamera2 软件包,请按板卡文档安装相机栈 / No python3-picamera2 package" -fi +ogscope_install_camera_stack_if_needed _apt_pause PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" @@ -214,6 +211,19 @@ if ! ogscope_verify_numpy_scipy; then fi echo "✅ numpy/scipy 已就绪 / numpy & scipy OK" +# TurboJPEG 是预览编码加速路径;缺失时应用会回退 OpenCV,但安装脚本应尽量保证可用。 +# TurboJPEG accelerates preview JPEG encoding; app can fall back, but installer should provide it. +if ! ogscope_verify_turbojpeg; then + echo "⚠️ TurboJPEG 不可用,尝试补装 libturbojpeg0 + PyTurboJPEG / TurboJPEG unavailable; installing fallback deps" + sudo apt install -y libturbojpeg0 + poetry run pip install --no-cache-dir "PyTurboJPEG>=1.7,<2" +fi +if ogscope_verify_turbojpeg; then + echo "✅ TurboJPEG 编码加速已就绪 / TurboJPEG encoder ready" +else + echo "⚠️ TurboJPEG 仍不可用,将自动回退 OpenCV / TurboJPEG still unavailable; OpenCV fallback will be used" +fi + VENV_PATH="$(poetry env info --path)" VENV_PYTHON="${VENV_PATH}/bin/python" if [ ! -x "${VENV_PYTHON}" ]; then @@ -283,6 +293,8 @@ else sudo chmod 640 "${OGSCOPE_ENV_FILE}" 2>/dev/null || true fi +ogscope_install_config_write_artifacts "${PROJECT_DIR}" "${USER}" + # ExecStart 使用 poetry env info --path(与 virtualenvs.in-project=true 时即项目 .venv),勿手写 ~/.virtualenvs/ # ExecStart uses poetry env path (project .venv when in-project=true); do not hardcode ~/.virtualenvs/ echo "⚙️ 写入 systemd: ${SERVICE_PATH}" diff --git a/scripts/mirror.sh b/scripts/mirror.sh index 7f0b9ee..b960842 100644 --- a/scripts/mirror.sh +++ b/scripts/mirror.sh @@ -228,6 +228,15 @@ ogscope_verify_numpy_scipy() { poetry run python -c "import numpy, scipy" 2>/dev/null } +# 验证 TurboJPEG Python 绑定与系统库均可用 / Verify Python binding and system lib are usable. +ogscope_verify_turbojpeg() { + poetry run python - <<'PY' >/dev/null 2>&1 +from turbojpeg import TurboJPEG + +TurboJPEG() +PY +} + # 若 systemd 已存在但 ExecStart 不是当前 Poetry venv,则修正(避免 ~/.virtualenvs/ 与项目 .venv 混用) # If unit exists but ExecStart points elsewhere than current Poetry venv, fix it (avoids ~/.virtualenvs vs .venv mismatch) # 参数 / Args: $1 = unit 文件路径 / unit file path, $2 = venv 内 python 可执行文件绝对路径 / absolute path to venv python @@ -412,6 +421,41 @@ ogscope_report_plate_solve_database_status() { echo "⚠️ Plate solving needs default_database.npz under data/plate_solve/; see docs/development/plate-solve-data.md" } +# 增量更新:同步网络相关工件(与近期 wifi-nm / systemd 文档一致) +# Board update: sync network artifacts (matches wifi-nm + systemd docs) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# $2 = 服务用户名(可选,默认 $USER)/ service user (optional, default $USER) +# 环境 / Env: OGSCOPE_SKIP_NETWORK_SYNC=1 跳过;需 sudo(免密或交互)/ skip; requires sudo +ogscope_install_config_write_artifacts() { + local project_dir="${1:?}" + local run_user="${2:-${USER:-}}" + local src="${project_dir}/scripts/ogscope-config-write.sh" + local dst="/usr/local/bin/ogscope-config-write" + local sudoers="/etc/sudoers.d/ogscope-config" + + if [ ! -f "${src}" ]; then + echo "⚠️ 未找到 ${src},跳过 config-write / Missing config-write script" + return 0 + fi + + echo "📝 安装 Web 配置写入助手 / Installing config-write helper → ${dst} ..." + sudo install -m 755 "${src}" "${dst}" + + if [ -z "${run_user}" ]; then + echo "⚠️ 未设置服务用户,跳过 ogscope-config sudoers / No service user; skip config sudoers" + return 0 + fi + + umask 077 + sudo tee "${sudoers}.tmp" >/dev/null </dev/null; then sudo env OGSCOPE_SERVICE_USER="${USER}" "${init_script}" ensure-systemd \ || echo "⚠️ ensure-systemd 失败;可手动: sudo env OGSCOPE_SERVICE_USER=\$USER ${init_script} ensure-systemd" + ogscope_install_config_write_artifacts "${project_dir}" "${USER}" else echo "⚠️ 无法免密 sudo,未运行 ensure-systemd;若 Web WiFi 异常请手动执行上述命令(见 docs/development/wifi-nm.md)" echo "⚠️ Non-interactive sudo unavailable; run ensure-systemd manually if WiFi/API issues" diff --git a/scripts/ogscope-config-write.sh b/scripts/ogscope-config-write.sh new file mode 100755 index 0000000..d4a9635 --- /dev/null +++ b/scripts/ogscope-config-write.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# 受控写入 /etc/ogscope/*.env(供 Web 配置 API 经 sudo 调用) +# Controlled write to /etc/ogscope/*.env (invoked via sudo from Web config API) +# +# 用法 / Usage: +# echo "KEY=value" | sudo -n ogscope-config-write /etc/ogscope/ogscope.env [mode] [group] +# +set -euo pipefail + +die() { + echo "ogscope-config-write: $*" >&2 + exit 1 +} + +dest="${1:-}" +mode="${2:-640}" +group="${3:-}" + +ALLOWED=( + "/etc/ogscope/ogscope.env" + "/etc/ogscope/network.env" +) + +[[ -n "${dest}" ]] || die "missing destination path" + +allowed=0 +for path in "${ALLOWED[@]}"; do + if [[ "${dest}" == "${path}" ]]; then + allowed=1 + break + fi +done +[[ "${allowed}" -eq 1 ]] || die "destination not allowed: ${dest}" + +if [[ ! "${mode}" =~ ^[0-7]{3,4}$ ]]; then + die "invalid mode: ${mode}" +fi + +if [[ -z "${group}" ]]; then + if [[ -e "${dest}" ]]; then + group="$(stat -c '%G' "${dest}" 2>/dev/null || true)" + fi + group="${group:-ogscope}" +fi + +mkdir -p "$(dirname "${dest}")" +tmp="$(mktemp "${dest}.tmp.XXXXXX")" +trap 'rm -f "${tmp}"' EXIT +cat >"${tmp}" +chown "root:${group}" "${tmp}" +chmod "${mode}" "${tmp}" +mv -f "${tmp}" "${dest}" +trap - EXIT diff --git a/scripts/ogscope-network-init.sh b/scripts/ogscope-network-init.sh index 478e424..4aee0bb 100755 --- a/scripts/ogscope-network-init.sh +++ b/scripts/ogscope-network-init.sh @@ -30,6 +30,10 @@ SWITCH_SRC="${SCRIPT_DIR}/ogscope-wifi-switch.sh" SWITCH_DST="/usr/local/bin/ogscope-wifi-switch" SUDOERS_D="/etc/sudoers.d/ogscope-wifi" SUDOERS_NMCLI="/etc/sudoers.d/ogscope-nmcli" +SUDOERS_CONFIG="/etc/sudoers.d/ogscope-config" +CONFIG_WRITE_SRC="${SCRIPT_DIR}/ogscope-config-write.sh" +CONFIG_WRITE_DST="/usr/local/bin/ogscope-config-write" +OGSCOPE_ENV_FILE="${ENV_DIR}/ogscope.env" # systemd drop-in:老部署主 unit 可能无 EnvironmentFile / Drop-in for units missing EnvironmentFile SYSTEMD_DROPIN_DIR="/etc/systemd/system/ogscope.service.d" SYSTEMD_NETWORK_ENV_CONF="${SYSTEMD_DROPIN_DIR}/ogscope-network-env.conf" @@ -104,8 +108,47 @@ write_sudoers_nmcli() { ok "已写入 ${SUDOERS_NMCLI}(免密 ${nmcli_bin},Web「激活」已保存 WiFi 等)" } +install_config_write_script() { + if [[ ! -f "${CONFIG_WRITE_SRC}" ]]; then + die "未找到 ${CONFIG_WRITE_SRC} / Config write script missing" + fi + install -m 755 "${CONFIG_WRITE_SRC}" "${CONFIG_WRITE_DST}" + ok "已安装 ${CONFIG_WRITE_DST}" +} + +write_sudoers_config() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 config sudoers / Skipping config sudoers" + return 0 + fi + umask 077 + cat >"${SUDOERS_CONFIG}.tmp" </dev/null || true + chmod 640 "${f}" 2>/dev/null || true + done + ok "已规范化 ogscope.env / network.env 权限为 root:${run_user} 640" +} + write_network_env() { local suffix="$1" + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" umask 077 cat >"${ENV_FILE}.tmp" </dev/null || true + fi + chmod 640 "${ENV_FILE}.tmp" mv "${ENV_FILE}.tmp" "${ENV_FILE}" ok "已写入 ${ENV_FILE}" } @@ -252,6 +298,9 @@ cmd_init() { ensure_ogscope_systemd_network_env write_sudoers write_sudoers_nmcli + install_config_write_script + write_sudoers_config + normalize_config_env_permissions set_hostname_avahi "${suffix}" ok "init 完成。请 systemctl restart ogscope 并连接热点 OGScope_${suffix} / init done" @@ -276,9 +325,20 @@ cmd_ensure_systemd() { fi ensure_ogscope_systemd_network_env write_sudoers_nmcli + install_config_write_script + write_sudoers_config + normalize_config_env_permissions info "请执行: sudo systemctl restart ogscope / Please run: sudo systemctl restart ogscope" } +cmd_ensure_config() { + require_root + install_config_write_script + write_sudoers_config + normalize_config_env_permissions + ok "config-write 与 sudoers 已就绪 / config-write and sudoers ready" +} + cmd_diag() { info "=== OGScope 网络诊断 / Network diagnostics ===" command -v nmcli >/dev/null && ok "nmcli: OK" || echo "❌ nmcli 缺失" @@ -287,6 +347,8 @@ cmd_diag() { [[ -f "${SWITCH_DST}" ]] && ok "切换脚本: ${SWITCH_DST}" || echo "⚠️ 无 ${SWITCH_DST}" [[ -f "${SUDOERS_D}" ]] && ok "sudoers: ${SUDOERS_D}" || echo "⚠️ 无 sudoers" [[ -f "${SUDOERS_NMCLI}" ]] && ok "sudoers nmcli: ${SUDOERS_NMCLI}" || echo "⚠️ 无 ${SUDOERS_NMCLI}(Web 激活 WiFi 可能报 Not authorized)" + [[ -f "${SUDOERS_CONFIG}" ]] && ok "sudoers config: ${SUDOERS_CONFIG}" || echo "⚠️ 无 ${SUDOERS_CONFIG}(Web 配置页可能无法保存)" + [[ -x "${CONFIG_WRITE_DST}" ]] && ok "config-write: ${CONFIG_WRITE_DST}" || echo "⚠️ 无 ${CONFIG_WRITE_DST}" command -v avahi-daemon >/dev/null && ok "avahi-daemon 已安装" || echo "⚠️ avahi-daemon 未安装" if command -v nmcli >/dev/null; then nmcli connection show "${AP_NAME}" >/dev/null 2>&1 && ok "连接 ${AP_NAME} 存在" || echo "⚠️ 无 ${AP_NAME}" @@ -346,9 +408,10 @@ main() { init) cmd_init "${1:-}" ;; diag) cmd_diag ;; ensure-systemd) cmd_ensure_systemd ;; + ensure-config) cmd_ensure_config ;; reset) cmd_reset "${1:-}" ;; *) - echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | reset [--yes]" >&2 + echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | ensure-config | reset [--yes]" >&2 exit 1 ;; esac diff --git a/scripts/spi_display_smoke_test.py b/scripts/spi_display_smoke_test.py index 7bbbe54..cffaa0d 100644 --- a/scripts/spi_display_smoke_test.py +++ b/scripts/spi_display_smoke_test.py @@ -44,7 +44,10 @@ def main() -> int: try: from ogscope.platform.hardware.st7796_spi import ST7796SPI except ImportError as e: - print("缺少依赖:在树莓派上 poetry install(需 spidev、RPi.GPIO)/ Missing deps:", e) + print( + "缺少依赖:在树莓派上 poetry install(需 spidev、RPi.GPIO)/ Missing deps:", + e, + ) return 1 from PIL import Image, ImageDraw, ImageFont diff --git a/scripts/sync_board_code.sh b/scripts/sync_board_code.sh new file mode 100755 index 0000000..a01d590 --- /dev/null +++ b/scripts/sync_board_code.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# 同步 OGScope 源码到开发板并执行 board-update(保留 uploads/logs/data) +# Sync OGScope source to dev board and run board-update (keeps uploads/logs/data) +# +# 用法 / Usage: +# export OGSCOPE_DEV_HOST=192.168.31.231 +# export OGSCOPE_DEV_USER=ogscope +# # 可选:非 IMX327 板卡可跳过摄像头 boot 配置 / Optional: skip camera boot config on non-IMX327 boards +# # export OGSCOPE_CAMERA=skip +# ./scripts/sync_board_code.sh +# +# 注意:勿对整仓使用 rsync --delete 且不排除 uploads/,否则会删除板上已上传的测试图片。 +# Note: Never full-repo rsync --delete without excluding uploads/ — it wipes board uploads. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEV_HOST="${OGSCOPE_DEV_HOST:-192.168.31.231}" +DEV_USER="${OGSCOPE_DEV_USER:-ogscope}" +DEV_PATH="${OGSCOPE_DEV_PATH:-/opt/ogscope}" +REMOTE="${DEV_USER}@${DEV_HOST}" + +RSYNC_SSH="ssh -o ConnectTimeout=15 -o BatchMode=yes" + +# 仅透传部署相关开关,避免把开发机的整个环境泄露到板端。 +# Forward only deployment switches, not the whole dev-machine environment. +_remote_update_env="" +for _env_name in \ + OGSCOPE_CAMERA \ + OGSCOPE_CAMERA_DEFAULT \ + OGSCOPE_SKIP_BOOT_CAMERA \ + OGSCOPE_SKIP_CAMERA_STACK \ + OGSCOPE_MIRROR \ + OGSCOPE_NONINTERACTIVE \ + POETRY_INSTALLER_MAX_WORKERS \ + OGSCOPE_DEVELOPMENT_MODE +do + if [ -n "${!_env_name+x}" ]; then + printf -v _env_value_quoted '%q' "${!_env_name}" + _remote_update_env+="${_env_name}=${_env_value_quoted} " + fi +done + +echo "== Sync OGScope code → ${REMOTE}:${DEV_PATH} (uploads/logs/data preserved) ==" + +rsync -avz --delete \ + -e "${RSYNC_SSH}" \ + --exclude '.git/' \ + --exclude '.venv/' \ + --exclude 'node_modules/' \ + --exclude '__pycache__/' \ + --exclude '.pytest_cache/' \ + --exclude '.coverage' \ + --exclude 'htmlcov/' \ + --exclude 'uploads/' \ + --exclude 'logs/' \ + --exclude 'data/' \ + "${ROOT}/" "${REMOTE}:${DEV_PATH}/" + +echo "== Remote board-update ==" +echo " Camera default: ${OGSCOPE_CAMERA:-${OGSCOPE_CAMERA_DEFAULT:-imx327}} (override with OGSCOPE_CAMERA=skip)" +ssh -o ConnectTimeout=15 -o BatchMode=yes "${REMOTE}" \ + "cd '${DEV_PATH}' && ${_remote_update_env}bash scripts/board-update.sh" + +echo "✅ OGScope sync complete" +echo " Health: http://${DEV_HOST}:8000/health" diff --git a/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf b/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf index b32eaf1..95f5a57 100644 --- a/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf +++ b/scripts/systemd/system/ogscope.service.d/ogscope-low-ram.conf @@ -5,7 +5,7 @@ [Service] Environment=OGSCOPE_SOLVER_MAX_STARS_HARD_CAP=40 Environment=OGSCOPE_SOLVER_MAX_IMAGE_SIDE_HARD_CAP=1280 -Environment=OGSCOPE_SHARED_PREVIEW_FPS=5 -Environment=OGSCOPE_PREVIEW_JPEG_QUALITY=65 +Environment=OGSCOPE_SHARED_PREVIEW_FPS=8 +Environment=OGSCOPE_PREVIEW_JPEG_QUALITY=55 # 与默认 config 一致允许多路 MJPEG(多标签/短暂重叠);需更多路可改大或设 0 不限制 / Match default cap; 0=unlimited Environment=OGSCOPE_STREAM_MAX_MJPEG_CLIENTS=4 diff --git a/tests/conftest.py b/tests/conftest.py index b147c19..115c6f0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,9 +19,9 @@ def client(): @pytest.fixture def temp_debug_dir(monkeypatch, tmp_path: Path): """将调试目录重定向到临时目录,避免污染用户目录。 / Redirect the debug directory to a temporary directory to avoid polluting the user directory.""" - from ogscope.web.api.debug import services as debug_services from ogscope.domain import shared as domain_shared_pkg from ogscope.domain.shared import filesystem as shared_fs + from ogscope.web.api.debug import services as debug_services debug_root = tmp_path / "dev_captures" debug_root.mkdir(parents=True, exist_ok=True) @@ -32,7 +32,9 @@ def temp_debug_dir(monkeypatch, tmp_path: Path): if hasattr(debug_services, "DEBUG_CAPTURES_DIR"): monkeypatch.setattr(debug_services, "DEBUG_CAPTURES_DIR", debug_root) if hasattr(domain_shared_pkg, "filesystem"): - monkeypatch.setattr(domain_shared_pkg.filesystem, "DEV_CAPTURES_DIR", debug_root) + monkeypatch.setattr( + domain_shared_pkg.filesystem, "DEV_CAPTURES_DIR", debug_root + ) monkeypatch.setattr(debug_services, "is_recording", False) monkeypatch.setattr(debug_services, "recording_task", None) monkeypatch.setattr(debug_services, "recording_stem", None) diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py index ed9e2d1..d2dc38b 100644 --- a/tests/unit/test_analysis_api.py +++ b/tests/unit/test_analysis_api.py @@ -83,6 +83,43 @@ def test_analysis_upload_and_single_image_solve( assert "status" in result +@pytest.mark.unit +def test_analysis_solve_image_overlay_ext( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """单图解算返回扩展叠加字段(含极轴引导)/ Image solve returns overlay extension.""" + image_path = tmp_path / "stars_polar.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + up = client.post( + "/api/dev/analysis/upload", + files={"file": ("stars_polar.jpg", f, "image/jpeg")}, + ) + assert up.status_code == 200 + + resp = client.post( + "/api/dev/analysis/solve/image", + json={ + "input_name": "stars_polar.jpg", + "hint_ra_deg": 45.0, + "hint_dec_deg": 70.0, + "overlay_topn_count": 2, + "enable_polar_guide": True, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + row = data.get("result") or {} + ext = row.get("overlay_ext") or {} + labels = ext.get("labels_topn") or [] + assert isinstance(labels, list) + assert len(labels) >= 1 + guide = ext.get("polar_guide") + assert isinstance(guide, dict) + assert "delta_px" in guide + + @pytest.mark.unit def test_analysis_extract_preview( client, temp_analysis_dir, monkeypatch, tmp_path: Path @@ -219,7 +256,9 @@ def test_analysis_list_presets_and_batch( ) assert exp.status_code == 200 - el = client.get("/api/dev/analysis/experiments", params={"page": 1, "page_size": 10}) + el = client.get( + "/api/dev/analysis/experiments", params={"page": 1, "page_size": 10} + ) assert el.status_code == 200 assert el.json()["total"] >= 1 diff --git a/tests/unit/test_camera_flip.py b/tests/unit/test_camera_flip.py index 4fc6439..43f714a 100644 --- a/tests/unit/test_camera_flip.py +++ b/tests/unit/test_camera_flip.py @@ -1,9 +1,14 @@ """IMX327 镜像几何单元测试(无相机硬件)/ Mirror geometry unit tests without camera hardware.""" +import sys +import types + import numpy as np import pytest +from ogscope.domain.camera.encoding import OpenCVEncoder, create_preview_encoder from ogscope.platform.hardware.camera import IMX327MIPICamera +from ogscope.web.camera_shared import CameraManager def _minimal_config(**extra: object) -> dict: @@ -49,3 +54,119 @@ def test_apply_flip_identity_when_disabled() -> None: img = np.arange(12, dtype=np.uint8).reshape(3, 4) out = cam._apply_flip(img) np.testing.assert_array_equal(out, img) + + +class _FakePicamera2: + """记录控制写入的 Picamera2 替身 / Picamera2 test double that records controls.""" + + def __init__(self) -> None: + self.controls_log: list[dict] = [] + self.camera_controls = {} + + def create_video_configuration(self, **kwargs): + return kwargs + + def configure(self, _config) -> None: + return None + + def set_controls(self, controls: dict) -> None: + self.controls_log.append(dict(controls)) + + +@pytest.mark.unit +def test_initialize_auto_white_balance_really_enables_awb(monkeypatch) -> None: + fake = _FakePicamera2() + monkeypatch.setitem( + sys.modules, + "picamera2", + types.SimpleNamespace(Picamera2=lambda: fake), + ) + + cam = IMX327MIPICamera(_minimal_config(white_balance_mode="auto")) + + assert cam.initialize() is True + assert any(item.get("AwbEnable") is True for item in fake.controls_log) + + +@pytest.mark.unit +def test_initialize_manual_white_balance_sets_colour_gains(monkeypatch) -> None: + fake = _FakePicamera2() + monkeypatch.setitem( + sys.modules, + "picamera2", + types.SimpleNamespace(Picamera2=lambda: fake), + ) + + cam = IMX327MIPICamera( + _minimal_config( + white_balance_mode="manual", + white_balance_gain_r=1.4, + white_balance_gain_b=1.8, + ) + ) + + assert cam.initialize() is True + assert any( + item.get("AwbEnable") is False and item.get("ColourGains") == (1.4, 1.8) + for item in fake.controls_log + ) + + +@pytest.mark.unit +def test_encode_frame_preserves_rgb_channel_order() -> None: + cv2 = pytest.importorskip("cv2") + rgb = np.zeros((8, 8, 3), dtype=np.uint8) + rgb[..., 0] = 240 + rgb[..., 1] = 30 + rgb[..., 2] = 10 + + data = CameraManager.encode_frame(rgb, "jpeg", 95) + assert data is not None + + decoded_bgr = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) + decoded_rgb = cv2.cvtColor(decoded_bgr, cv2.COLOR_BGR2RGB) + mean_rgb = decoded_rgb.reshape(-1, 3).mean(axis=0) + + assert mean_rgb[0] > mean_rgb[2] * 4 + + +@pytest.mark.unit +def test_preview_encoder_falls_back_to_opencv_when_turbojpeg_missing( + monkeypatch, +) -> None: + """TurboJPEG 缺失时必须安全回退 / Missing TurboJPEG must safely fall back.""" + monkeypatch.setitem(sys.modules, "turbojpeg", types.SimpleNamespace()) + + encoder = create_preview_encoder("turbojpeg") + + assert isinstance(encoder, OpenCVEncoder) + + +@pytest.mark.unit +def test_frame_duration_limits_allow_long_auto_exposure() -> None: + cam = IMX327MIPICamera( + _minimal_config(fps=8, auto_exposure=True, auto_exposure_max_us=2_000_000) + ) + + assert cam._compute_frame_duration_limits() == (125_000, 2_000_000) + + +@pytest.mark.unit +def test_frame_duration_limits_follow_manual_exposure() -> None: + cam = IMX327MIPICamera( + _minimal_config(fps=8, auto_exposure=False, exposure_us=250_000) + ) + + assert cam._compute_frame_duration_limits() == (250_000, 250_000) + + +@pytest.mark.unit +def test_unsupported_noise_reduction_control_is_skipped() -> None: + fake = _FakePicamera2() + fake.camera_controls = {} + cam = IMX327MIPICamera(_minimal_config(noise_reduction_mode="high_quality")) + cam.camera = fake + + cam._apply_noise_reduction_controls() + + assert fake.controls_log == [] diff --git a/tests/unit/test_camera_manager_health.py b/tests/unit/test_camera_manager_health.py index 0961e2e..738c830 100644 --- a/tests/unit/test_camera_manager_health.py +++ b/tests/unit/test_camera_manager_health.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + import numpy as np import pytest @@ -28,7 +30,11 @@ def get_video_frame(self): class _FrameCamera(_NoFrameCamera): + def __init__(self) -> None: + self.read_count = 0 + def get_video_frame(self): + self.read_count += 1 return np.zeros((360, 640, 3), dtype=np.uint8) @@ -59,3 +65,42 @@ async def test_ensure_started_succeeds_when_frames_available() -> None: assert status["streaming"] is True await manager.stop() + + +@pytest.mark.asyncio +async def test_ensure_started_fast_path_does_not_probe_again() -> None: + """新鲜相机重复ensure不应额外抓帧 / Fresh repeated ensure must not grab another frame.""" + manager = CameraManager() + camera = _FrameCamera() + manager.attach_camera_instance(camera) + + await manager.ensure_started() + first_reads = camera.read_count + await manager.ensure_started() + + assert first_reads == 1 + assert camera.read_count == first_reads + await manager.stop() + + +@pytest.mark.asyncio +async def test_preview_consumer_stops_grabber_on_last_release() -> None: + """最后一个预览消费者离开后停止编码任务 / Stop encoding after the last preview consumer.""" + manager = CameraManager() + manager._idle_shutdown_sec = 60 + manager.attach_camera_instance(_FrameCamera()) + + await manager.acquire_preview_consumer() + await asyncio.sleep(0.03) + await manager.release_preview_consumer() + + metrics = await manager.stream_metrics() + assert metrics["preview_consumers"] == 0 + assert manager._grabber_task is None + await manager.stop() + + +def test_preview_fps_is_independent_runtime_setting() -> None: + manager = CameraManager() + assert manager.set_preview_fps(12) == 12 + assert manager._target_fps == 12 diff --git a/tests/unit/test_config_catalog.py b/tests/unit/test_config_catalog.py index ac3d922..8fc0254 100644 --- a/tests/unit/test_config_catalog.py +++ b/tests/unit/test_config_catalog.py @@ -12,12 +12,13 @@ def test_build_config_catalog_includes_new_preview_fields() -> None: catalog = build_config_catalog() keys = { - entry["key"] - for section in catalog["sections"] - for entry in section["entries"] + entry["key"] for section in catalog["sections"] for entry in section["entries"] } assert "OGSCOPE_SHARED_PREVIEW_FPS" in keys assert "OGSCOPE_PREVIEW_JPEG_QUALITY" in keys + assert "OGSCOPE_PREVIEW_ENCODER" in keys + assert "OGSCOPE_CAMERA_AUTO_EXPOSURE_MAX_US" in keys + assert "OGSCOPE_CAMERA_NOISE_REDUCTION_MODE" in keys assert "OGSCOPE_SIMULATION_MODE" in keys diff --git a/tests/unit/test_config_files.py b/tests/unit/test_config_files.py new file mode 100644 index 0000000..7a8306c --- /dev/null +++ b/tests/unit/test_config_files.py @@ -0,0 +1,46 @@ +"""配置 env 文件读写辅助测试 / Tests for config env file helpers.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ogscope.web.api.system import config_files as mod + + +@pytest.mark.unit +def test_read_config_file_payload_marks_sudo_writable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + env_path = tmp_path / "ogscope.env" + env_path.write_text("OGSCOPE_PORT=8000\n", encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_WRITE_SCRIPT", tmp_path / "write.sh") + monkeypatch.setattr(mod, "CONFIG_SUDOERS", tmp_path / "sudoers") + mod.CONFIG_WRITE_SCRIPT.write_text("#!/bin/sh\n", encoding="utf-8") + mod.CONFIG_SUDOERS.write_text( + "ogscope ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-config-write\n" + ) + + payload = mod.read_config_file_payload(env_path) + + assert payload["exists"] is True + assert payload["writable_via_sudo"] is True + assert payload["writable"] is True + + +@pytest.mark.unit +def test_read_config_file_payload_not_writable_without_sudoers( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + env_path = tmp_path / "ogscope.env" + env_path.write_text("OGSCOPE_PORT=8000\n", encoding="utf-8") + monkeypatch.setattr(mod, "CONFIG_WRITE_SCRIPT", tmp_path / "missing-write.sh") + monkeypatch.setattr(mod, "CONFIG_SUDOERS", tmp_path / "missing-sudoers") + monkeypatch.setattr(mod.os, "access", lambda _path, _mode: False) + + payload = mod.read_config_file_payload(env_path) + + assert payload["writable_via_sudo"] is False + assert payload["writable"] is False diff --git a/tests/unit/test_core_contract_api.py b/tests/unit/test_core_contract_api.py index e6afa0d..86cf914 100644 --- a/tests/unit/test_core_contract_api.py +++ b/tests/unit/test_core_contract_api.py @@ -19,6 +19,82 @@ def test_core_system_status(client) -> None: assert "system" in data assert "hardware_plane" in data assert "hardware_plane" in data + assert "health_reasons" in data + assert isinstance(data["health_reasons"], list) + if data["health"] == "healthy": + assert data["health_reasons"] == [] + else: + assert len(data["health_reasons"]) >= 1 + + +@pytest.mark.unit +def test_core_system_status_health_reasons() -> None: + """health_reasons 反映相机与网络降级 / health_reasons reflect camera and network degradation.""" + from ogscope.core.application.core_service import CoreContractService + + reasons = CoreContractService._health_reasons( + CoreContractService._normalize_camera_status( + {"connected": False, "error": "Camera not initialized"}, + ), + {"error": "wifi_not_configured"}, + network_in_health_scope=True, + ) + assert "camera_not_connected" in reasons + assert "network_wifi_not_configured" in reasons + + +@pytest.mark.unit +def test_core_system_status_health_reasons_ignore_delegated_network() -> None: + """职责外网络不参与 health / Delegated network does not affect health.""" + from ogscope.core.application.core_service import CoreContractService + + reasons = CoreContractService._health_reasons( + CoreContractService._normalize_camera_status({"connected": True}), + {"error": "wifi_not_configured"}, + network_in_health_scope=False, + ) + assert reasons == [] + + +@pytest.mark.unit +def test_core_camera_ambient_hint_from_metadata() -> None: + """相机 metadata 生成环境亮度建议 / Camera metadata builds ambient hint.""" + from ogscope.core.application.core_service import CoreContractService + + normalized = CoreContractService._normalize_camera_status( + { + "connected": True, + "streaming": True, + "recording": False, + "info": { + "lux": 4.0, + "actual_exposure_us": 80_000, + "auto_exposure_max_us": 100_000, + "actual_digital_gain": 2.0, + }, + } + ) + + hint = normalized["ambient_hint"] + assert hint["available"] is True + assert hint["source"] == "camera_metadata" + assert 0.0 <= hint["dark_score"] <= 1.0 + assert hint["exposure_us"] == 80_000 + + +@pytest.mark.unit +def test_core_system_status_network_delegated_when_subordinate(monkeypatch) -> None: + """subordinate 下 network 标记 delegated 且不降级 / Subordinate marks network delegated.""" + from ogscope.core.application.core_service import CoreContractService + + network = CoreContractService._build_network_status( + {"role": "subordinate", "subordinate_mode": True}, + {"wifi_signal_dbm": -50.0, "wifi_quality": 88.0}, + ) + assert network["managed_by"] == "external" + assert network["in_health_scope"] is False + assert network["error"] is None + assert network["signal_dbm"] == -50.0 @pytest.mark.unit @@ -118,11 +194,15 @@ async def _fake_stop_camera(): monkeypatch.setattr( core_service.core_contract_service, "get_camera_status", _fake_camera_status ) - monkeypatch.setattr(core_service.core_contract_service, "tune_camera", _fake_camera_tune) + monkeypatch.setattr( + core_service.core_contract_service, "tune_camera", _fake_camera_tune + ) monkeypatch.setattr( core_service.core_contract_service, "start_camera", _fake_start_camera ) - monkeypatch.setattr(core_service.core_contract_service, "stop_camera", _fake_stop_camera) + monkeypatch.setattr( + core_service.core_contract_service, "stop_camera", _fake_stop_camera + ) monkeypatch.setattr( core_service.core_contract_service, "get_stream_status", _fake_stream_status ) diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py index 2fb7e74..a99f487 100644 --- a/tests/unit/test_debug_camera_api.py +++ b/tests/unit/test_debug_camera_api.py @@ -24,6 +24,9 @@ def __init__(self): self.exposure_us = 10000 self.analogue_gain = 1.0 self.digital_gain = 1.0 + self.noise_reduction_mode = "fast" + self.ae_flicker_mode = "off" + self.auto_exposure_max_us = 2_000_000 def get_camera_info(self): return { @@ -41,6 +44,19 @@ def get_camera_info(self): "exposure_us": self.exposure_us, "analogue_gain": self.analogue_gain, "digital_gain": self.digital_gain, + "noise_reduction_mode": self.noise_reduction_mode, + "ae_flicker_mode": self.ae_flicker_mode, + "auto_exposure_max_us": self.auto_exposure_max_us, + "driver": "fake", + "backend": "unit-test", + "lores_enabled": False, + "lores_available": False, + "capabilities": { + "awb_modes": ["auto", "manual", "night"], + "noise_reduction_modes": ["off", "fast", "high_quality"], + "ae_flicker": False, + "manual_digital_gain": True, + }, } def start_capture(self): @@ -92,6 +108,19 @@ def set_image_enhancement(self, contrast, brightness, saturation, sharpness): return True def set_noise_reduction(self, level): + self.noise_reduction_mode = "off" if int(level) <= 0 else "fast" + return True + + def set_noise_reduction_mode(self, mode): + self.noise_reduction_mode = str(mode) + return True + + def set_ae_flicker_mode(self, mode): + self.ae_flicker_mode = str(mode) + return True + + def set_auto_exposure_max_us(self, value): + self.auto_exposure_max_us = int(value) return True def set_white_balance(self, mode, gain_r=1.0, gain_b=1.0): @@ -221,6 +250,9 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): "saturation": 1.0, "sharpness": 1.0, "noiseReduction": 1, + "noiseReductionMode": "high_quality", + "aeFlickerMode": "50hz", + "autoExposureMaxUs": 1000000, "whiteBalanceMode": "auto", "whiteBalanceGainR": 1.0, "whiteBalanceGainB": 1.0, @@ -232,11 +264,16 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): body = response.json() assert body["success"] is True assert body["settings"]["exposure"] == 12000 + assert fake_camera_env.noise_reduction_mode == "high_quality" + assert fake_camera_env.ae_flicker_mode == "50hz" + assert fake_camera_env.auto_exposure_max_us == 1000000 @pytest.mark.unit def test_debug_camera_auto_exposure_switch_success(client, fake_camera_env): - response = client.post("/api/dev/debug/camera/auto-exposure", params={"enabled": False}) + response = client.post( + "/api/dev/debug/camera/auto-exposure", params={"enabled": False} + ) assert response.status_code == 200 body = response.json() assert body["success"] is True diff --git a/tests/unit/test_dev_contract_api.py b/tests/unit/test_dev_contract_api.py index e0ca105..ab79da3 100644 --- a/tests/unit/test_dev_contract_api.py +++ b/tests/unit/test_dev_contract_api.py @@ -58,4 +58,3 @@ def test_dev_hardware_plane_metrics_include_profile(client) -> None: def test_legacy_debug_path_not_exposed(client) -> None: resp = client.get("/api/debug/camera/status") assert resp.status_code in {404, 405} - diff --git a/tests/unit/test_domain_camera_sidecar.py b/tests/unit/test_domain_camera_sidecar.py index 281827b..7857a99 100644 --- a/tests/unit/test_domain_camera_sidecar.py +++ b/tests/unit/test_domain_camera_sidecar.py @@ -39,4 +39,3 @@ def test_merge_capture_sidecar_does_not_override_existing_fields() -> None: merge_capture_sidecar_into_info(info, capture_info) assert info["resolution"] == "640x480" - diff --git a/tests/unit/test_domain_camera_streaming.py b/tests/unit/test_domain_camera_streaming.py index 5a11548..18e3e1c 100644 --- a/tests/unit/test_domain_camera_streaming.py +++ b/tests/unit/test_domain_camera_streaming.py @@ -35,7 +35,9 @@ async def release(self) -> None: @pytest.mark.unit @pytest.mark.asyncio -async def test_build_camera_mjpeg_stream_rejects_when_limit_reached(monkeypatch) -> None: +async def test_build_camera_mjpeg_stream_rejects_when_limit_reached( + monkeypatch, +) -> None: limiter = _FakeLimiter(can_acquire=False) monkeypatch.setattr(streaming_mod, "get_mjpeg_stream_limiter", lambda: limiter) @@ -60,10 +62,27 @@ async def test_build_camera_mjpeg_stream_yields_frame_and_releases(monkeypatch) class _FakeSettings: stream_mjpeg_frame_fetch_timeout_ms = 1000 + shared_preview_fps = 8 monkeypatch.setattr(streaming_mod, "get_settings", lambda: _FakeSettings()) - async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id: int): + class _FakeManager: + acquired = False + released = False + preview_target_fps = 8 + + async def acquire_preview_consumer(self) -> None: + self.acquired = True + + async def release_preview_consumer(self) -> None: + self.released = True + + manager = _FakeManager() + monkeypatch.setattr(streaming_mod, "get_camera_manager", lambda: manager) + + async def _fake_get_stream_frame_bytes( + fmt: str, quality: int, *, since_frame_id: int + ): _ = fmt, quality, since_frame_id return 200, b"abc", 1 @@ -87,4 +106,5 @@ async def _fake_get_stream_frame_bytes(fmt: str, quality: int, *, since_frame_id assert b"Content-Type: image/jpeg" in first_chunk await body_iter.aclose() assert limiter.released is True - + assert manager.acquired is True + assert manager.released is True diff --git a/tests/unit/test_domain_shared_filesystem.py b/tests/unit/test_domain_shared_filesystem.py index c945ba0..77e430a 100644 --- a/tests/unit/test_domain_shared_filesystem.py +++ b/tests/unit/test_domain_shared_filesystem.py @@ -34,4 +34,3 @@ def test_ensure_safe_basename_accepts_valid_names(name: str) -> None: def test_ensure_safe_basename_rejects_invalid_names(name: str) -> None: with pytest.raises(ValueError): ensure_safe_basename(name) - diff --git a/tests/unit/test_hardware_plane.py b/tests/unit/test_hardware_plane.py index cc866cd..5314e18 100644 --- a/tests/unit/test_hardware_plane.py +++ b/tests/unit/test_hardware_plane.py @@ -38,7 +38,9 @@ async def test_hardware_plane_daemon_minimal_methods() -> None: @pytest.mark.unit @pytest.mark.asyncio -async def test_hardware_plane_daemon_subordinate_profile_disables_local_services() -> None: +async def test_hardware_plane_daemon_subordinate_profile_disables_local_services() -> ( + None +): daemon = HardwarePlaneDaemon( enable_local_sensors=False, enable_hmi=False, @@ -62,7 +64,9 @@ async def test_hardware_plane_daemon_subordinate_profile_disables_local_services @pytest.mark.asyncio async def test_jsonrpc_uds_sensor_read_roundtrip(tmp_path: Path) -> None: _ = tmp_path - socket_path = Path("/tmp") / f"external-sensor-{os.getpid()}-{int(time.time() * 1000)}.sock" + socket_path = ( + Path("/tmp") / f"external-sensor-{os.getpid()}-{int(time.time() * 1000)}.sock" + ) async def _handler(method: str, params: dict[str, object]) -> dict[str, object]: if method != "sensor.read": @@ -108,4 +112,3 @@ def test_runtime_profile_subordinate_disables_ui_hmi_local_sensors() -> None: assert profile["enable_hmi"] is False assert profile["enable_ui"] is True assert profile["enable_local_sensors"] is False - diff --git a/tests/unit/test_plate_large_scale_bg.py b/tests/unit/test_plate_large_scale_bg.py index e10f22b..8676ff9 100644 --- a/tests/unit/test_plate_large_scale_bg.py +++ b/tests/unit/test_plate_large_scale_bg.py @@ -2,6 +2,7 @@ 大尺度背景减除单元测试 / Unit tests for large-scale background flattening. """ +import cv2 import numpy as np import pytest @@ -25,3 +26,25 @@ def test_subtract_large_scale_background_bgr_non_bgr_passthrough() -> None: """非三通道图原样返回 / Non-3-channel frames pass through unchanged.""" gray = np.zeros((10, 10), dtype=np.uint8) assert subtract_large_scale_background_bgr(gray, downsample_max_side=32) is gray + + +@pytest.mark.unit +def test_background_optimization_matches_reference_math() -> None: + """复用缓冲后的结果应与原公式一致 / Buffer reuse must preserve reference output.""" + rng = np.random.default_rng(42) + bgr = rng.integers(0, 256, size=(96, 128, 3), dtype=np.uint8) + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) + sw, sh = 64, 48 + small = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_AREA) + bg_small = cv2.GaussianBlur(small, (0, 0), sigmaX=2.0, sigmaY=2.0) + bg = cv2.resize(bg_small, (128, 96), interpolation=cv2.INTER_LINEAR).astype( + np.float32 + ) + corr = np.clip(gray - bg + float(np.mean(gray)), 1e-3, 255.0) + ratio = np.clip(corr / np.maximum(gray, 1e-3), 0.0, 4.0) + expected = np.clip( + np.round(bgr.astype(np.float32) * ratio[..., np.newaxis]), 0, 255 + ).astype(np.uint8) + + actual = subtract_large_scale_background_bgr(bgr, downsample_max_side=64) + np.testing.assert_array_equal(actual, expected) diff --git a/tests/unit/test_sensor_solve_context.py b/tests/unit/test_sensor_solve_context.py new file mode 100644 index 0000000..6a85fda --- /dev/null +++ b/tests/unit/test_sensor_solve_context.py @@ -0,0 +1,87 @@ +"""Tests for sensor-assisted solve context / 传感器辅助解算上下文测试.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from ogscope.algorithms.plate_solve.sensor_context import ( + attach_sensor_prediction, + local_sidereal_time_deg, +) +from ogscope.web.api.models.schemas import AnalysisSolveImageRequest + + +def _solve_context(*, azimuth_deg: float = 0.0, altitude_deg: float = 90.0) -> dict: + return { + "observer": { + "latitude_deg": 0.0, + "longitude_deg": 0.0, + "altitude_m": 0.0, + "time_utc": "2000-01-01T12:00:00Z", + "source": "test", + }, + "orientation": { + "azimuth_deg": azimuth_deg, + "altitude_deg": altitude_deg, + "heading_deg": azimuth_deg, + "source": "test", + }, + "quality": { + "gps_valid": True, + "time_valid": True, + "heading_valid": True, + "mount_valid": True, + }, + } + + +def test_analysis_solve_image_accepts_solve_context() -> None: + """旧请求兼容并接受新字段 / Old requests remain compatible and accept new field.""" + old_req = AnalysisSolveImageRequest.model_validate({"input_name": "stars.jpg"}) + assert old_req.solve_context is None + + req = AnalysisSolveImageRequest.model_validate( + {"input_name": "stars.jpg", "solve_context": _solve_context()} + ) + assert req.solve_context is not None + assert req.solve_context.quality.gps_valid is True + + +def test_sensor_prediction_matches_zenith_at_equator() -> None: + """赤道天顶预测应落在赤纬 0 附近 / Equator zenith predicts near Dec 0.""" + row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} + context = _solve_context() + expected_ra = local_sidereal_time_deg( + 0.0, datetime(2000, 1, 1, 12, tzinfo=timezone.utc) + ) + row["ra_deg"] = expected_ra + attach_sensor_prediction(row, context) + + pred = row["sensor_prediction"] + assert pred["sensor_status"] == "matched" + assert pred["predicted_dec_deg"] == pytest.approx(0.0, abs=1e-6) + assert pred["predicted_ra_deg"] == pytest.approx(expected_ra, abs=1e-6) + assert pred["sensor_delta_deg"] == pytest.approx(0.0, abs=1e-6) + + +def test_sensor_prediction_flags_mismatch() -> None: + """偏差过大时标记 mismatch / Large delta only marks mismatch.""" + row = {"status": "MATCH_FOUND", "ra_deg": 180.0, "dec_deg": 0.0} + attach_sensor_prediction(row, _solve_context(), threshold_deg=25.0) + + assert row["status"] == "MATCH_FOUND" + assert row["sensor_prediction"]["sensor_status"] == "mismatch" + assert row["sensor_prediction"]["sensor_delta_deg"] > 25.0 + + +def test_sensor_prediction_unavailable_when_time_invalid() -> None: + """传感器时间无效时保持 unavailable / Invalid sensor time stays unavailable.""" + context = _solve_context() + context["quality"]["time_valid"] = False + context["observer"]["time_utc"] = None + row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} + attach_sensor_prediction(row, context) + + assert row["sensor_prediction"]["sensor_status"] == "unavailable" diff --git a/tests/unit/test_system_wifi_parse.py b/tests/unit/test_system_wifi_parse.py index f3e206e..9b5246a 100644 --- a/tests/unit/test_system_wifi_parse.py +++ b/tests/unit/test_system_wifi_parse.py @@ -4,7 +4,6 @@ import pytest -from ogscope.web.api.system import services as system_services from ogscope.web.api.system.services import SystemInfoService _WIRELESS_SAMPLE = """Inter-| sta-| Quality | Discarded packets diff --git a/tests/unit/test_wifi_switch.py b/tests/unit/test_wifi_switch.py index 0769de6..8a05c78 100644 --- a/tests/unit/test_wifi_switch.py +++ b/tests/unit/test_wifi_switch.py @@ -10,7 +10,10 @@ import pytest from ogscope.config import Settings -from ogscope.platform.hardware.wifi_switch import WifiSwitchService, _parse_status_output +from ogscope.platform.hardware.wifi_switch import ( + WifiSwitchService, + _parse_status_output, +) @pytest.mark.unit @@ -119,7 +122,9 @@ async def _fake_switch_mode(mode: str): _ = mode return network_routes.wifi_domain_service.build_wifi_status() - monkeypatch.setattr(network_routes.wifi_domain_service, "switch_mode", _fake_switch_mode) + monkeypatch.setattr( + network_routes.wifi_domain_service, "switch_mode", _fake_switch_mode + ) response = client.get("/api/network/wifi") assert response.status_code == 200 @@ -139,7 +144,9 @@ def test_network_wifi_scan_api(client, monkeypatch) -> None: async def _fake_scan_wifi(): return [{"ssid": "Home", "signal": 80, "security": "WPA2"}], None - monkeypatch.setattr(network_routes.wifi_domain_service, "scan_wifi", _fake_scan_wifi) + monkeypatch.setattr( + network_routes.wifi_domain_service, "scan_wifi", _fake_scan_wifi + ) response = client.get("/api/network/wifi/scan") assert response.status_code == 200 @@ -163,7 +170,9 @@ async def _fake_profiles(): } ] - monkeypatch.setattr(network_routes.wifi_domain_service, "list_profiles", _fake_profiles) + monkeypatch.setattr( + network_routes.wifi_domain_service, "list_profiles", _fake_profiles + ) response = client.get("/api/network/wifi/profiles") assert response.status_code == 200 data = response.json() diff --git a/web/spa/src/apps/camera/CameraConsoleApp.tsx b/web/spa/src/apps/camera/CameraConsoleApp.tsx index 1433ec6..0a1b3c3 100644 --- a/web/spa/src/apps/camera/CameraConsoleApp.tsx +++ b/web/spa/src/apps/camera/CameraConsoleApp.tsx @@ -46,10 +46,34 @@ type CameraInfo = { saturation?: number; sharpness?: number; noise_reduction?: number; + noise_reduction_mode?: string; + ae_flicker_mode?: string; + auto_exposure_max_us?: number; white_balance_mode?: string; white_balance_gain_r?: number; white_balance_gain_b?: number; color_mode?: string; + actual_digital_gain?: number; + colour_temperature?: number; + lux?: number; + frame_duration_limits?: number[]; + lores_enabled?: boolean; + lores_available?: boolean; + lores_width?: number; + lores_height?: number; + lores_format?: string; + capabilities?: { + awb_modes?: string[]; + ae_flicker?: boolean; + noise_reduction_modes?: string[]; + manual_digital_gain?: boolean; + lores_stream?: boolean; + autofocus?: boolean; + hdr?: boolean; + [key: string]: unknown; + }; + driver?: string; + backend?: string; rotation?: number; flip_horizontal?: boolean; flip_vertical?: boolean; @@ -69,6 +93,35 @@ type CameraStatus = { runtime_overrides?: Record; }; +type StreamMetrics = { + target_preview_fps?: number; + sensor_target_fps?: number; + preview_target_fps?: number; + actual_capture_fps?: number; + actual_preview_fps?: number; + actual_exposure_us?: number; + frame_duration_us?: number; + preview_consumers?: number; + analysis_consumers?: number; + recording_consumers?: number; + jpeg_average_encode_ms?: number; + jpeg_cached_bytes?: number; + preview_encoder?: string; + jpeg_encode_failures?: number; + jpeg_source_format?: string; + camera_driver?: string; + camera_backend?: string; + lores_enabled?: boolean; + lores_available?: boolean; + lores_width?: number; + lores_height?: number; + lores_format?: string; + throttle_reason?: string | null; + process_rss_kb?: number; + process_swap_kb?: number; + cma_free_kb?: number; +}; + type CameraForm = { exposure: number; gain: number; @@ -79,6 +132,9 @@ type CameraForm = { saturation: number; sharpness: number; noiseReduction: number; + noiseReductionMode: string; + aeFlickerMode: string; + autoExposureMaxUs: number; whiteBalanceMode: string; whiteBalanceGainR: number; whiteBalanceGainB: number; @@ -126,7 +182,7 @@ type DebugFileInfo = { fps?: number; }; -const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1080"] as const; +const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1020"] as const; const ROTATION_PRESETS = [0, 90, 180, 270] as const; const FILE_PAGE_SIZE = 12; @@ -229,7 +285,8 @@ export function CameraConsoleApp() { const [previewBusy, setPreviewBusy] = useState(false); const [recordBusy, setRecordBusy] = useState(false); const [captureBusy, setCaptureBusy] = useState(false); - const [fpsValue, setFpsValue] = useState("5"); + const [fpsValue, setFpsValue] = useState("8"); + const [previewFpsValue, setPreviewFpsValue] = useState("8"); const [resValue, setResValue] = useState("1280x720"); const [samplingMode, setSamplingMode] = useState("supersample"); const [runtimeDirty, setRuntimeDirty] = useState(false); @@ -242,7 +299,7 @@ export function CameraConsoleApp() { /** 最近约 1s 内画面像素变化次数(rAF 采样)/ ~1s sliding window from pixel deltas */ const [liveFps, setLiveFps] = useState(0); /** 与 OGSCOPE_SHARED_PREVIEW_FPS 一致:共享抓帧与 MJPEG 最小帧间隔 / Env stream pacing cap */ - const [streamPacingFps, setStreamPacingFps] = useState(null); + const [streamMetrics, setStreamMetrics] = useState(null); const [recordElapsed, setRecordElapsed] = useState(0); const [rotationValue, setRotationValue] = useState(180); const [flipHorizontal, setFlipHorizontal] = useState(false); @@ -257,6 +314,9 @@ export function CameraConsoleApp() { saturation: 1.0, sharpness: 1.0, noiseReduction: 0, + noiseReductionMode: "fast", + aeFlickerMode: "off", + autoExposureMaxUs: 2000000, whiteBalanceMode: "auto", whiteBalanceGainR: 1.0, whiteBalanceGainB: 1.0, @@ -357,14 +417,14 @@ export function CameraConsoleApp() { setPreviewBusy(true); setErr(null); try { - // 先卸载预览,释放长连接,再通知后端停止 / Release stream before stop API + // 仅卸载预览流,后端会释放消费者并让相机热驻留后延迟关闭。 + // Only detach the preview stream; backend releases the consumer and keeps the camera warm briefly. clearReconnectTimer(); setPreviewActive(false); previewActiveRef.current = false; setPreviewStreamHint(null); setPreviewStreamIsBusy(false); resetStreamStats(); - setStatus((prev) => (prev ? { ...prev, streaming: false, recording: false } : prev)); if (imgRef.current) { imgRef.current.onload = null; imgRef.current.onerror = null; @@ -372,10 +432,7 @@ export function CameraConsoleApp() { imgRef.current.removeAttribute("src"); } setStreamNonce(Date.now()); - await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); - await requestJson("/api/debug/camera/stop", { method: "POST" }); setNotice(t("cam.notice.previewStop")); - await updateCameraStatus(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } finally { @@ -423,6 +480,10 @@ export function CameraConsoleApp() { try { const fps = clamp(parseInt(fpsValue, 10) || 5, 1, 60); await requestJson(`/api/debug/camera/fps?fps=${fps}`, { method: "POST" }); + const previewFps = clamp(parseInt(previewFpsValue, 10) || 8, 1, 30); + await requestJson(`/api/debug/camera/preview-fps?fps=${previewFps}`, { + method: "POST", + }); const [w, h] = resValue.split("x").map((x) => parseInt(x, 10)); if (w && h) { await requestJson(`/api/debug/camera/size?width=${w}&height=${h}`, { method: "POST" }); @@ -457,25 +518,22 @@ export function CameraConsoleApp() { const applyModeSettings = async () => { setErr(null); try { - await requestJson(`/api/debug/camera/auto-exposure?enabled=${form.autoExposure ? "true" : "false"}`, { - method: "POST", - }); - await requestJson( - `/api/debug/camera/white-balance?mode=${encodeURIComponent(form.whiteBalanceMode)}&gain_r=${form.whiteBalanceGainR}&gain_b=${form.whiteBalanceGainB}`, - { method: "POST" }, - ); - await requestJson(`/api/debug/camera/color-mode?color_mode=${encodeURIComponent(form.colorMode)}`, { + await requestJson("/api/debug/camera/settings", { method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), }); setNotice(t("cam.notice.modeApplied")); + setFormDirty(false); await updateCameraStatus(); } catch (e) { setErr(e instanceof Error ? e.message : String(e)); } }; - const syncFormFromStatus = (info: CameraInfo | undefined) => { + const syncFormFromStatus = (info: CameraInfo | undefined, options?: { syncRuntime?: boolean }) => { if (!info) return; + const syncRuntime = options?.syncRuntime ?? true; setForm({ exposure: clamp(Math.round(toNum(info.exposure_us, 5000)), 100, 120000), gain: clamp(toNum(info.analogue_gain, 1.0), 1.0, 24.0), @@ -486,15 +544,22 @@ export function CameraConsoleApp() { saturation: clamp(toNum(info.saturation, 1.0), 0, 2), sharpness: clamp(toNum(info.sharpness, 1.0), 0, 2), noiseReduction: clamp(Math.round(toNum(info.noise_reduction, 0)), 0, 4), + noiseReductionMode: String(info.noise_reduction_mode ?? "fast"), + aeFlickerMode: String(info.ae_flicker_mode ?? "off"), + autoExposureMaxUs: clamp(Math.round(toNum(info.auto_exposure_max_us, 2000000)), 10000, 10000000), whiteBalanceMode: String(info.white_balance_mode ?? "auto"), whiteBalanceGainR: clamp(toNum(info.white_balance_gain_r, 1.0), 0.1, 3.0), whiteBalanceGainB: clamp(toNum(info.white_balance_gain_b, 1.0), 0.1, 3.0), colorMode: String(info.color_mode ?? "color"), }); - setFpsValue(String(Math.round(toNum(info.fps, 5)))); - setResValue(`${Math.round(toNum(info.width, 1280))}x${Math.round(toNum(info.height, 720))}`); - setSamplingMode(String(info.sampling_mode ?? "supersample")); - setRuntimeDirty(false); + if (syncRuntime) { + // 用户修改运行时参数但尚未应用时,轮询状态不应把选项弹回旧值。 + // Do not let status polling snap runtime controls back while the user has unapplied edits. + setFpsValue(String(Math.round(toNum(info.fps, 8)))); + setResValue(`${Math.round(toNum(info.width, 1280))}x${Math.round(toNum(info.height, 720))}`); + setSamplingMode(String(info.sampling_mode ?? "supersample")); + setRuntimeDirty(false); + } setRotationValue(clamp(Math.round(toNum(info.rotation, 180)), 0, 270)); setFlipHorizontal(Boolean(info.flip_horizontal)); setFlipVertical(Boolean(info.flip_vertical)); @@ -846,8 +911,8 @@ export function CameraConsoleApp() { useEffect(() => { if (!status?.info || formDirty) return; - syncFormFromStatus(status.info); - }, [status?.info, formDirty]); + syncFormFromStatus(status.info, { syncRuntime: !runtimeDirty }); + }, [status?.info, formDirty, runtimeDirty]); useEffect(() => { if (!status?.recording) { @@ -950,7 +1015,7 @@ export function CameraConsoleApp() { useEffect(() => { if (!previewActive) { - setStreamPacingFps(null); + setStreamMetrics(null); return; } let cancelled = false; @@ -961,11 +1026,16 @@ export function CameraConsoleApp() { credentials: "same-origin", }); if (!res.ok || cancelled) return; - const j = (await res.json()) as { target_preview_fps?: number }; - const v = Number(j.target_preview_fps ?? 0); - if (!cancelled) setStreamPacingFps(Number.isFinite(v) && v >= 0 ? v : null); + const j = (await res.json()) as StreamMetrics; + if (!cancelled) { + setStreamMetrics(j); + const v = Number(j.preview_target_fps ?? j.target_preview_fps ?? 0); + if (Number.isFinite(v) && v > 0 && !runtimeDirty) { + setPreviewFpsValue(String(Math.round(v))); + } + } } catch { - if (!cancelled) setStreamPacingFps(null); + if (!cancelled) setStreamMetrics(null); } }; void pull(); @@ -974,7 +1044,7 @@ export function CameraConsoleApp() { cancelled = true; window.clearInterval(id); }; - }, [previewActive]); + }, [previewActive, runtimeDirty]); useEffect(() => { if (!notice) return; @@ -994,6 +1064,14 @@ export function CameraConsoleApp() { const streamSrc = previewActive ? `${debugApi("/camera/stream")}?t=${streamNonce}` : ""; const exposureLocked = form.autoExposure; const wbManual = form.whiteBalanceMode === "manual"; + const caps = status?.info?.capabilities ?? {}; + const wbModeOptions = Array.isArray(caps.awb_modes) && caps.awb_modes.length > 0 + ? caps.awb_modes + : ["auto", "daylight", "cloudy", "tungsten", "fluorescent", "indoor", "manual", "night"]; + const nrModeOptions = Array.isArray(caps.noise_reduction_modes) && caps.noise_reduction_modes.length > 0 + ? caps.noise_reduction_modes + : ["off", "fast", "high_quality"]; + const digitalGainWritable = caps.manual_digital_gain !== false; const nightModeEnabled = Boolean(status?.info?.night_mode); const isStreaming = previewActive; const canStartPreview = !previewBusy && !previewActive && !Boolean(status?.recording); @@ -1140,7 +1218,7 @@ export function CameraConsoleApp() {

- {t("cam.preview.state")}: {status?.streaming ? t("cam.state.streaming") : t("cam.state.idle")} + {t("cam.preview.state")}: {previewActive ? t("cam.state.streaming") : t("cam.state.idle")}
{previewStreamHint && !previewActive && ( @@ -1260,7 +1338,9 @@ export function CameraConsoleApp() {
{t("cam.stats.frameFps")}: - {liveFps.toFixed(2)} + + {Number(streamMetrics?.actual_preview_fps ?? liveFps).toFixed(2)} +

{t("cam.stats.fpsMeasureNote")}

@@ -1272,12 +1352,52 @@ export function CameraConsoleApp() {
{t("cam.stats.streamPacingFps")}: - {streamPacingFps != null ? String(streamPacingFps) : "—"} + {streamMetrics?.preview_target_fps ?? streamMetrics?.target_preview_fps ?? "—"}

{t("cam.stats.streamPacingHint")}

+
+ {t("cam.stats.captureFps")}: + + {Number(streamMetrics?.actual_capture_fps ?? 0).toFixed(2)} + +
+
+ {t("cam.stats.encodeMs")}: + + {Number(streamMetrics?.jpeg_average_encode_ms ?? 0).toFixed(1)} ms + +
+
+ {t("cam.stats.encoder")}: + + {String(streamMetrics?.preview_encoder ?? "—")} + +

+ {`${streamMetrics?.jpeg_source_format ?? "RGB888"} / fail ${streamMetrics?.jpeg_encode_failures ?? 0}`} +

+
+
+ {t("cam.stats.consumers")}: + + {`${streamMetrics?.preview_consumers ?? 0}/${streamMetrics?.analysis_consumers ?? 0}/${streamMetrics?.recording_consumers ?? 0}`} + +
+
+ {t("cam.stats.cameraMemory")}: + + {`${Math.round(Number(streamMetrics?.process_rss_kb ?? 0) / 1024)} / ${Math.round(Number(streamMetrics?.process_swap_kb ?? 0) / 1024)} MB`} + +
+ {streamMetrics?.throttle_reason === "auto_exposure_long" && ( +
+ {t("cam.stats.longExposureThrottle", { + exposure: Math.round(Number(streamMetrics.actual_exposure_us ?? 0) / 1000), + })} +
+ )}
{t("cam.stats.uptime")}: {streamStartedAtRef.current != null ? `${Math.max(0, Math.round((performance.now() - streamStartedAtRef.current) / 1000))}s` : "0s"} @@ -1286,6 +1406,20 @@ export function CameraConsoleApp() { {t("cam.system.sensor")}: {String(status?.info?.sensor ?? "—")}
+
+ {t("cam.stats.driver")}: + {String(status?.info?.driver ?? streamMetrics?.camera_driver ?? "—")} +

+ {`${status?.info?.backend ?? streamMetrics?.camera_backend ?? "—"} · lores ${status?.info?.lores_available || streamMetrics?.lores_available ? "on" : "off"}`} +

+
+
+ {t("cam.stats.metadata")}: + {status?.info?.lux != null ? `${Number(status.info.lux).toFixed(1)} lux` : "—"} +

+ {status?.info?.colour_temperature != null ? `${Math.round(Number(status.info.colour_temperature))}K` : "—"} +

+
{t("cam.controls.resolution")}: {`${status?.info?.width ?? "—"}x${status?.info?.height ?? "—"}`} @@ -1419,9 +1553,15 @@ export function CameraConsoleApp() {
+ +
+
{ setFormDirty(true); setForm((p) => ({ ...p, contrast: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, brightness: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, saturation: Number(v.toFixed(1)) })); }} /> { setFormDirty(true); setForm((p) => ({ ...p, sharpness: Number(v.toFixed(1)) })); }} />
+ {!digitalGainWritable && ( +

+ {t("cam.controls.digitalGainReadOnly")}: {Number(status?.info?.actual_digital_gain ?? form.digitalGain).toFixed(2)} +

+ )}
diff --git a/web/spa/src/apps/lab/AnalysisLabApp.tsx b/web/spa/src/apps/lab/AnalysisLabApp.tsx index 54024d5..7004ed0 100644 --- a/web/spa/src/apps/lab/AnalysisLabApp.tsx +++ b/web/spa/src/apps/lab/AnalysisLabApp.tsx @@ -667,7 +667,7 @@ export default function AnalysisLabApp() { stopCameraSolveLoop(); } else if (fromLoop) { clearCameraSolveSchedule(); - const wait = Math.max(50, Number(effInt ?? starAnalysisIntervalMs)); + const wait = Math.max(50, Number(nextAllowed ?? effInt ?? starAnalysisIntervalMs)); cameraSolveTimeoutRef.current = window.setTimeout(() => { cameraSolveTimeoutRef.current = null; if (cameraSolveRunningRef.current) void runCameraFrameSolve(true); @@ -749,7 +749,7 @@ export default function AnalysisLabApp() { setLastRoundTripMs(performance.now() - t0); if (fromLoop) { clearFileSolveSchedule(); - const wait = Math.max(50, Number(effInt ?? starAnalysisIntervalMs)); + const wait = Math.max(50, Number(nextAllowed ?? effInt ?? starAnalysisIntervalMs)); fileSolveTimeoutRef.current = window.setTimeout(() => { fileSolveTimeoutRef.current = null; if (fileSolveRunningRef.current) void runVideoFileSolve(true); @@ -2302,6 +2302,9 @@ export default function AnalysisLabApp() { effective: starAnalysisIntervalMs, })}

+

+ {t("params.solveIntervalIndependent")} +

{view === "lab_video" && (