diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1bb87f..bcd1393 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,6 +5,9 @@ on: tags: - 'v*' +permissions: + contents: write + jobs: release: runs-on: ubuntu-latest @@ -23,7 +26,7 @@ jobs: run: poetry build - name: 创建 GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: dist/* generate_release_notes: true @@ -35,4 +38,3 @@ jobs: # run: poetry publish # env: # POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }} - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e37ef4..b8dc389 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -132,7 +132,7 @@ 流程:`feature/*` / `fix/*` → PR → `staging` → 测试通过后 PR → `main`。 -**禁止直接向 `main` 或 `staging` 提交或推送**(本地 pre-commit 会拦截;remote 需配置 GitHub Rulesets 或 Branch protection,见 [.github/scripts/apply-branch-protection.sh](../.github/scripts/apply-branch-protection.sh))。 +**禁止直接向 `main` 或 `staging` 提交或推送**(本地 pre-commit 会拦截;remote 需配置 GitHub Rulesets 或 Branch protection,见 [.github/scripts/apply-branch-protection.sh](.github/scripts/apply-branch-protection.sh))。 **已废弃**:不再使用 `develop`、`dev`、`dev-latest` 集成分支。 diff --git a/CONTRIBUTING_EN.md b/CONTRIBUTING_EN.md index 08f1090..f795e34 100644 --- a/CONTRIBUTING_EN.md +++ b/CONTRIBUTING_EN.md @@ -87,7 +87,7 @@ Thank you for your interest in OGScope. We welcome contributions of all kinds. Flow: `feature/*` / `fix/*` → PR → `staging` → validated PR → `main`. -**Do not commit or push directly to `main` or `staging`.** Local pre-commit blocks this; configure GitHub Rulesets or branch protection via [.github/scripts/apply-branch-protection.sh](../.github/scripts/apply-branch-protection.sh). +**Do not commit or push directly to `main` or `staging`.** Local pre-commit blocks this; configure GitHub Rulesets or branch protection via [.github/scripts/apply-branch-protection.sh](.github/scripts/apply-branch-protection.sh). **Deprecated**: `develop`, `dev`, and `dev-latest` integration branches. diff --git a/README.md b/README.md index ae8dda0..5c05844 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ ### Phase 2 - 完整功能 - ⏳ SPI 屏幕显示 -- ⏳ 自动板块求解 +- ✅ Tetra3 自动板块求解 +- ✅ 实时解算与调试控制台 - ⏳ 移动 App 控制 - ⏳ 校准数据管理 @@ -77,7 +78,7 @@ python -m ogscope.main ### Web 界面访问 -启动后访问: [http://raspberrypi.local:8000](http://raspberrypi.local:8000) 或 http://:8000 +启动后访问:[http://raspberrypi.local:8000](http://raspberrypi.local:8000) 或 `http://<设备IP>:8000` ## 文档 @@ -141,4 +142,4 @@ OGScope/ ## 贡献 -欢迎提交 Issue 和 Pull Request!详见 [贡献指南](CONTRIBUTING.md) \ No newline at end of file +欢迎提交 Issue 和 Pull Request!详见 [贡献指南](CONTRIBUTING.md) diff --git a/README_EN.md b/README_EN.md index b56dd94..5fc4b8a 100644 --- a/README_EN.md +++ b/README_EN.md @@ -22,7 +22,8 @@ English | [中文](README.md) ### Phase 2 - Complete Features - ⏳ SPI screen display -- ⏳ Automatic plate solving +- ✅ Tetra3 automatic plate solving +- ✅ Realtime solve and debug console - ⏳ Mobile app control - ⏳ Calibration data management @@ -43,13 +44,13 @@ English | [中文](README.md) - **Processor**: Raspberry Pi Zero 2W (ARM Cortex-A53) - **Camera**: IMX327 sensor (1920x1080) - **Display**: 2.4" SPI LCD (240x320) -- **Software**: Python 3.9 + FastAPI +- **Software**: Python 3.10+ + FastAPI ## Quick Start ### Requirements -- Python 3.9+ +- Python 3.10+ - Poetry 1.2+ - Raspberry Pi Zero 2W (Raspberry Pi OS) @@ -139,4 +140,3 @@ See [LICENSE](LICENSE) file for details. ## Contributing Issues and Pull Requests are welcome! See [Contributing Guide](CONTRIBUTING.md) for details. - diff --git a/deploy/ogscope.env.example b/deploy/ogscope.env.example index 71a1ef7..6ee867c 100644 --- a/deploy/ogscope.env.example +++ b/deploy/ogscope.env.example @@ -41,7 +41,10 @@ OGSCOPE_RELOAD=false # OGSCOPE_CAMERA_EXPOSURE=10000 # OGSCOPE_CAMERA_GAIN=1.0 # OGSCOPE_CAMERA_AE_POLAR_PRESET=true -# OGSCOPE_CAMERA_AE_EXPOSURE_VALUE=0.35 +# OGSCOPE_CAMERA_AE_EXPOSURE_VALUE=1.0 +# OGSCOPE_CAMERA_AE_AGGRESSIVE_ENABLED=true +# OGSCOPE_CAMERA_AUTO_EXPOSURE_MAX_US=1000000 +# OGSCOPE_CAMERA_TUNING_FILE=/path/to/imx327-override.json # OGSCOPE_CAMERA_FLIP_HORIZONTAL=false # OGSCOPE_CAMERA_FLIP_VERTICAL=false @@ -54,6 +57,7 @@ OGSCOPE_RELOAD=false # OGSCOPE_KEEP_RAW_CACHE=false # OGSCOPE_STREAM_MAX_MJPEG_CLIENTS=4 # OGSCOPE_STREAM_MJPEG_FRAME_FETCH_TIMEOUT_MS=20000 +# OGSCOPE_STREAM_MJPEG_CLIENT_STALL_TIMEOUT_MS=30000 # --- SPI 屏幕 / SPI display --- # OGSCOPE_DISPLAY_ENABLED=false diff --git a/docs/DEBUG_CONSOLE.md b/docs/DEBUG_CONSOLE.md index f5b5cbe..7566735 100644 --- a/docs/DEBUG_CONSOLE.md +++ b/docs/DEBUG_CONSOLE.md @@ -13,6 +13,13 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 - 支持启动/停止预览 - 实时状态显示:采集帧率、预览帧率、曝光、消费者数量、编码器与内存压力 +### ✨ 星点焦点校准 +- 一键进入焦点校准,自动持续测量多颗可用星点的 HFD 与 FWHM +- 用“继续、接近最佳、向错误方向”等直观提示引导用户缓慢旋转镜头 +- 记录本次调焦的最佳 HFD 与变化趋势;数值越低通常越锐利 +- 可直接点击预览中的星点锁定目标,也可恢复自动多星统计 +- 自动排除饱和、信噪比不足或形状异常的星点,避免误导 + ### 📸 拍摄控制 - **单张拍摄**: 拍摄高质量照片并自动保存 - **视频录制**: 手动控制录制时长,支持MP4格式 @@ -20,7 +27,7 @@ OGScope 调试控制台是一个专为开发者设计的相机调试工具,提 - **参数记录**: 每次拍摄自动生成参数记录文件 ### ⚙️ 参数设置 -- **曝光时间**: 1ms - 100ms (微秒级调节) +- **曝光时间**: 0.1ms–1s(微秒级调节,与自动曝光上限一致) - **模拟增益**: 1x - 16x (0.1x步进) - **数字增益**: 1x - 4x (0.1x步进) - **白平衡**: `auto` / `manual` / `night`,手动模式可设置红/蓝增益 @@ -83,23 +90,29 @@ python -m ogscope.web.app - 拖动滑块调整曝光和增益 - 点击 "应用设置" 使参数生效 -3. **拍摄照片** +3. **校准焦点(可选)** + - 点击预览区的“开始焦点校准” + - 工具会锁定当前实测曝光/增益并临时关闭降噪,完成或离开时恢复原设置 + - 缓慢旋转镜头,观察 HFD 数值与趋势提示 + - 让 HFD 尽可能接近本次最佳值;小星点未被自动选中时,直接点击画面中的星点强制局部测量 + +4. **拍摄照片** - 切换到 "拍摄控制" 标签页 - 点击 "拍摄照片" 按钮 - 照片自动保存到 `~/dev_captures/` 目录 -4. **录制视频** +5. **录制视频** - 点击 "开始录制" 按钮 - 录制过程中显示计时器 - 点击 "停止录制" 结束录制 -5. **管理预设** +6. **管理预设** - 切换到 "预设管理" 标签页 - 输入预设名称和描述 - 点击 "保存预设" 保存当前设置 - 点击预设卡片上的 "应用" 快速切换 -6. **查看文件** +7. **查看文件** - 切换到 "文件管理" 标签页 - 查看所有拍摄文件 - 点击 "下载" 下载文件到本地 @@ -151,6 +164,7 @@ python -m ogscope.web.app - `POST /api/dev/debug/camera/start` - 启动相机 - `POST /api/dev/debug/camera/stop` - 停止相机 - `GET /api/dev/debug/camera/preview` - 获取预览图像 +- `GET /api/dev/debug/camera/focus/metrics` - 获取当前帧的星点焦点指标 ### 拍摄功能 - `POST /api/dev/debug/camera/capture` - 拍摄照片 @@ -224,7 +238,8 @@ python scripts/test_debug_console.py --test deps | `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_tuning_file` | 空 | 可选 Picamera2 tuning 覆盖路径;默认加载产品内置 IMX327 tuning | +| `camera_auto_exposure_max_us` | `1000000` | 自动曝光最长帧周期,暗场允许降至约 1 fps | | `camera_ae_flicker_mode` | `off` | `off` / `50hz` / `60hz` | | `camera_noise_reduction_mode` | `fast` | `off` / `fast` / `high_quality` | | `camera_lores_enabled` | `true` | 启用低分辨率辅助流统计 | diff --git a/docs/DEBUG_CONSOLE_EN.md b/docs/DEBUG_CONSOLE_EN.md index 117c826..0d709f5 100644 --- a/docs/DEBUG_CONSOLE_EN.md +++ b/docs/DEBUG_CONSOLE_EN.md @@ -13,6 +13,13 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt - Start/stop preview - Live status: capture FPS, preview FPS, exposure, consumers, encoder, and memory pressure +### Star focus calibration +- Start a guided focusing session and continuously measure multi-star HFD and FWHM. +- Follow plain-language guidance such as keep turning, near best, or wrong direction. +- Track the best HFD and recent trend for the current session; lower is normally sharper. +- Click a star in the preview to lock it, or return to automatic multi-star aggregation. +- Saturated, low-SNR, and badly shaped candidates are excluded to avoid misleading scores. + ### Capture - **Still capture**: high-quality photos with auto-save - **Video recording**: manual duration, MP4 @@ -20,7 +27,7 @@ The OGScope debug console is a developer-focused camera tool: live preview, capt - **Sidecar metadata**: `.txt` parameter file per capture ### Parameters -- **Exposure**: 1ms–100ms (fine steps) +- **Exposure**: 0.1ms–1s (fine steps, aligned with the auto-exposure ceiling) - **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 @@ -68,10 +75,11 @@ Browser: `http://localhost:8000/debug` 1. **Start preview** — click Start, wait for init, view stream. 2. **Tune parameters** — Parameters tab, adjust sliders, Apply. -3. **Capture still** — Capture tab, Capture photo; files under `~/dev_captures/`. -4. **Record video** — Start recording, stop when done. -5. **Presets** — Presets tab: name, description, Save; Apply from cards. -6. **Files** — Files tab: list, download, details. +3. **Calibrate focus (optional)** — start focus calibration, rotate the lens slowly, and minimize HFD. Click a preview star when you want to lock the target. +4. **Capture still** — Capture tab, Capture photo; files under `~/dev_captures/`. +5. **Record video** — Start recording, stop when done. +6. **Presets** — Presets tab: name, description, Save; Apply from cards. +7. **Files** — Files tab: list, download, details. ### Keyboard shortcuts @@ -115,6 +123,7 @@ Browser: `http://localhost:8000/debug` - `POST /api/dev/debug/camera/start` - `POST /api/dev/debug/camera/stop` - `GET /api/dev/debug/camera/preview` +- `GET /api/dev/debug/camera/focus/metrics` ### Capture - `POST /api/dev/debug/camera/capture` @@ -179,7 +188,8 @@ These settings enter runtime through environment variables or config files. Name | `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_tuning_file` | empty | Optional Picamera2 tuning override path; the bundled IMX327 tuning is used by default | +| `camera_auto_exposure_max_us` | `1000000` | Longest AE frame duration; dark scenes may fall to about 1 fps | | `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 | diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index f943b0c..be455d3 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -1,293 +1,147 @@ # OGScope 快速开始指南 -本指南将帮助你快速搭建 OGScope 开发环境。 - 中文 | [English](QUICK_START_EN.md) -## 🎯 目标 - -- ✅ 在 Raspberry Pi Zero 2W 上运行 OGScope -- ✅ 配置 PyCharm Professional 远程开发 -- ✅ 通过 Web 界面访问系统 +本指南用于在 Raspberry Pi Zero 2W 上完成首次安装和基础验证。完整安装参数、WiFi、低内存优化与日常升级见[开发指南](development/README.md)。 -## 📋 准备工作 +## 1. 准备 -### 硬件要求 +### 硬件 - Raspberry Pi Zero 2W -- IMX327 相机模块 -- MicroSD 卡 (32GB+) -- 5V 3A 电源适配器 -- (可选)2.4寸 SPI LCD 屏幕 +- IMX327 MIPI 相机 +- 32 GB 或更大的 MicroSD 卡 +- 5 V / 2 A 或更高规格的稳定电源 -### 软件要求 +### 软件 -#### 开发机 (Mac) -- macOS -- PyCharm Professional 2021.1.3 或更高版本 +- Raspberry Pi OS Lite 64-bit(Debian/apt 系) +- Python 3.10+ - Git +- 首次安装时可访问 apt、PyPI 和 GitHub -#### Raspberry Pi -- Raspberry Pi OS(官方镜像) -- Python 3.9+ -- 网络连接(WiFi 或有线) - -## 🚀 第一步:Raspberry Pi 系统配置 +建议在 Raspberry Pi Imager 中预先设置主机名、SSH、公钥或初始用户。不要依赖历史默认用户名或默认密码。 -### 1.1 烧录系统 +## 2. 连接开发板 ```bash -# 1. 下载 Raspberry Pi Imager 工具 -# 访问: https://www.raspberrypi.org/downloads/ - -# 2. 使用 Raspberry Pi Imager 烧录 Raspberry Pi OS Lite -# 或使用 dd 命令烧录到 SD 卡 -# macOS/Linux: -sudo dd if=2024-01-15-raspios-bookworm-armhf-lite.img of=/dev/diskX bs=4m status=progress +ssh @raspberrypi.local +# mDNS 不可用时改用路由器分配的 IP +ssh @ ``` -### 1.2 首次启动 +推荐配置 SSH 公钥: ```bash -# 1. 插入 SD 卡并启动 Raspberry Pi -# 2. 默认用户名: pi -# 3. 默认密码: raspberry - -# 首次登录后,建议修改密码 -passwd +ssh-copy-id @ +ssh -o BatchMode=yes @ true ``` -### 1.3 配置网络 +## 3. 克隆并安装 ```bash -# 方法 1: WiFi 连接 -sudo nmcli dev wifi connect "WiFi名称" password "密码" - -# 方法 2: 配置静态 IP(可选) -sudo nano /etc/network/interfaces - -# 查看 IP 地址 -ip addr show wlan0 # WiFi -ip addr show eth0 # 有线 +git clone https://github.com/OG-star-tech/OGScope.git +cd OGScope +chmod +x scripts/bootstrap.sh +./scripts/bootstrap.sh ``` -### 1.4 SSH 访问 +`bootstrap.sh` 会把运行代码部署到默认目录 `/opt/ogscope`,然后调用安装流程创建项目虚拟环境、补齐系统依赖并注册 `ogscope.service`。 + +国内网络可显式选择镜像: ```bash -# 在 Mac 上测试 SSH 连接 -ssh pi@raspberrypi.local -# 或使用 IP 地址 -ssh pi@192.168.1.xxx +export OGSCOPE_MIRROR=cn +./scripts/bootstrap.sh ``` -## 🔧 第二步:安装 OGScope - -### 2.1 自动安装(推荐) +只需要最小运行环境时: ```bash -# 在 Raspberry Pi 上执行 -cd ~ -git clone https://github.com/your-username/OGScope.git -cd OGScope -chmod +x scripts/install.sh -./scripts/install.sh +OGSCOPE_BOOTSTRAP_MODE=min ./scripts/bootstrap.sh ``` -安装脚本会自动: -- 安装系统依赖 -- 安装 Poetry -- 创建 Python 虚拟环境 -- 安装项目依赖 -- 配置 systemd 服务 - -### 2.2 手动安装 - -如果自动安装失败,可以手动执行: +如果代码已经位于目标运行目录,也可以直接执行: ```bash -# 1. 更新系统 -sudo apt update && sudo apt upgrade -y - -# 2. 安装系统依赖 -sudo apt install -y python3.9 python3-pip python3-venv git \ - build-essential libopencv-dev libjpeg-dev libpng-dev \ - libspidev-dev v4l-utils - -# 3. 安装 Poetry -curl -sSL https://install.python-poetry.org | python3 - -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -source ~/.bashrc - -# 4. 克隆项目(如果还没有) -cd ~ -git clone https://github.com/your-username/OGScope.git -cd OGScope - -# 5. 安装 Python 依赖 -poetry install - -# 6. 运行测试 -poetry run python -m ogscope.main +./scripts/install.sh ``` -## 💻 第三步:配置 PyCharm 远程开发 - -### 3.1 配置 SSH 免密登录 - -在 **Mac** 上执行: +## 4. 验证 ```bash -# 生成 SSH 密钥(如果还没有) -ssh-keygen -t ed25519 -C "ogscope-dev" - -# 复制公钥到 Raspberry Pi -ssh-copy-id orangepi@orangepi.local - -# 配置 SSH config -cat >> ~/.ssh/config << 'EOF' -Host orangepi - HostName orangepi.local - User orangepi - Port 22 - ForwardAgent yes - ServerAliveInterval 60 -EOF - -# 测试连接 -ssh orangepi +sudo systemctl status ogscope --no-pager +curl -fsS http://127.0.0.1:8000/health +curl -fsS http://127.0.0.1:8000/api/core/v1/system/status ``` -### 3.2 PyCharm 配置 - -建议以 [开发指南](./development/README.md) 中的“远程开发(手动部署 + systemd)”流程为准。 - -**快速版本**: +浏览器访问: -1. **添加远程解释器** - - `File` → `Settings` → `Project` → `Python Interpreter` - - 点击 ⚙️ → `Add...` → `SSH Interpreter` - - Host: `orangepi.local`, User: `orangepi` - - Interpreter: `/home/orangepi/.local/bin/poetry` +- 首页:`http://:8000/` +- 调试控制台:`http://:8000/debug` +- API 文档:`http://:8000/docs` -2. **配置自动部署** - - `Tools` → `Deployment` → `Configuration` - - 添加 SFTP 服务器 - - 设置路径映射 - -3. **启用自动上传** - - `Tools` → `Deployment` → `Automatic Upload` ✅ - -## 🌐 第四步:访问 Web 界面 - -### 4.1 启动服务 +相机检查: ```bash -# 方法 1: 手动启动(开发模式) -cd ~/OGScope -poetry run python -m ogscope.main - -# 方法 2: 使用 systemd(生产模式) -sudo systemctl start ogscope -sudo systemctl status ogscope +rpicam-hello --list-cameras 2>/dev/null || libcamera-hello --list-cameras +curl -fsS http://127.0.0.1:8000/api/core/v1/camera/status ``` -### 4.2 访问界面 +`POST /api/core/v1/camera/start` 只有在相机确认 `connected=true` 且 `streaming=true` 时才返回成功。 -在浏览器中打开: -``` -http://orangepi.local:8000 -# 或使用 IP 地址 -http://192.168.1.xxx:8000 -``` +## 5. 日常更新 -### 4.3 API 文档 - -FastAPI 自动生成的文档: -``` -http://orangepi.local:8000/docs # Swagger UI -http://orangepi.local:8000/redoc # ReDoc +```bash +cd /opt/ogscope +./scripts/board-update.sh ``` -## 🔍 验证安装 - -### 检查清单 - -- [ ] Raspberry Pi 可以正常启动 -- [ ] SSH 可以连接 -- [ ] Poetry 已安装 -- [ ] OGScope 依赖已安装 -- [ ] Web 服务可以启动 -- [ ] 浏览器可以访问界面 -- [ ] PyCharm 可以远程连接 - -### 运行测试 +需要开发依赖时: ```bash -# 在 Raspberry Pi 上 -cd ~/OGScope -poetry run pytest tests/unit/ +OGSCOPE_INSTALL_DEV=1 ./scripts/board-update.sh ``` -## 🐛 故障排除 - -### 问题 1: 找不到 Raspberry Pi +## 6. 本地开发 ```bash -# 方法 1: 使用 IP 地址 -ip addr show wlan0 +poetry install +poetry run pytest -q -# 方法 2: 路由器管理界面查看 -# 方法 3: 使用 nmap 扫描 -nmap -sn 192.168.1.0/24 +cd web/spa +npm ci +npm run build ``` -### 问题 2: Poetry 安装失败 +涉及相机、GPIO、I2C 或 systemd 的修改必须在真实开发板上复验。开发板同步流程见[开发指南](development/README.md)和 `scripts/sync_dev_board.sh`。 -```bash -# 使用 pip 安装(备选) -pip3 install poetry -``` +## 7. 常见问题 -### 问题 3: Web 服务无法启动 +### 服务启动失败 ```bash -# 查看日志 -journalctl -u ogscope -f - -# 检查端口占用 -sudo netstat -tunlp | grep 8000 - -# 手动启动查看错误 -cd ~/OGScope -poetry run python -m ogscope.main +sudo journalctl -u ogscope -b --no-pager -n 200 +sudo systemctl cat ogscope ``` -### 问题 4: PyCharm 无法连接 +### 相机不可用 ```bash -# 检查 SSH 服务 -sudo systemctl status ssh - -# 检查防火墙 -sudo ufw status - -# 测试 SSH 连接 -ssh -v orangepi@orangepi.local +rpicam-hello --list-cameras 2>/dev/null || libcamera-hello --list-cameras +sudo journalctl -u ogscope -b --no-pager | grep -i -E 'camera|libcamera|imx327' ``` -## 📚 下一步 - -- 阅读 [用户手册](./user_guide/user-manual.md) -- 查看 [开发文档](./development/README.md) -- 开始 [硬件组装](./hardware/assembly-guide.md) - -## 🆘 获取帮助 +确认相机 overlay、排线方向和 `/boot/firmware/config.txt` 后再重启。 -- [GitHub Issues](https://github.com/your-username/OGScope/issues) -- [GitHub Discussions](https://github.com/your-username/OGScope/discussions) -- [查看文档](./README.md) +### 网络或热点问题 ---- +以 [WiFi / NetworkManager 指南](development/wifi-nm.md)为准,不要同时使用多套网络初始化脚本。 -祝你使用愉快!🎉 +## 8. 后续文档 +- [文档索引](README.md) | [English](README_EN.md) +- [开发指南](development/README.md) | [English](development/README_EN.md) +- [Core REST v1 契约](contracts/core-rest-v1.md) | [English](contracts/core-rest-v1_EN.md) +- [调试控制台](DEBUG_CONSOLE.md) | [English](DEBUG_CONSOLE_EN.md) +- [问题反馈](https://github.com/OG-star-tech/OGScope/issues) diff --git a/docs/QUICK_START_EN.md b/docs/QUICK_START_EN.md index 451f27c..fb62a99 100644 --- a/docs/QUICK_START_EN.md +++ b/docs/QUICK_START_EN.md @@ -1,158 +1,147 @@ # OGScope Quick Start Guide -This guide will help you quickly set up the OGScope development environment. - English | [中文](QUICK_START.md) -## 🎯 Goals - -- ✅ Run OGScope on Raspberry Pi Zero 2W -- ✅ Configure PyCharm Professional remote development -- ✅ Access the system through web interface +Use this guide for a first installation and basic validation on a Raspberry Pi Zero 2W. See the [Development Guide](development/README_EN.md) for all installer options, WiFi, low-memory tuning, and routine updates. -## 📋 Prerequisites +## 1. Prepare -### Hardware Requirements +### Hardware -- Raspberry Pi Zero 2W development board -- IMX327 camera module -- 2.4" SPI LCD display -- MicroSD card (32GB+) -- Power supply (5V/2A) +- Raspberry Pi Zero 2W +- IMX327 MIPI camera +- 32 GB or larger MicroSD card +- Stable 5 V / 2 A or better power supply -### Software Requirements +### Software -- macOS/Windows/Linux development machine -- PyCharm Professional 2025 -- Python 3.9+ -- Poetry package manager +- Raspberry Pi OS Lite 64-bit (Debian/apt based) +- Python 3.10+ +- Git +- Access to apt, PyPI, and GitHub during first installation -## 🚀 Installation Steps +Use Raspberry Pi Imager to configure the hostname, SSH, public key, and initial user. Do not rely on historical default usernames or passwords. -### Step 1: Prepare Raspberry Pi Zero 2W +## 2. Connect to the board -1. **Flash the OS** - ```bash - # Download Raspberry Pi OS image for Raspberry Pi Zero 2W - # Flash to microSD card using balenaEtcher - ``` +```bash +ssh @raspberrypi.local +# Use the router-assigned IP when mDNS is unavailable +ssh @ +``` -2. **Initial Setup** - ```bash - # Boot the board and connect via SSH - ssh pi@orangepi.local - - # Update system - sudo apt update && sudo apt upgrade -y - - # Install essential packages - sudo apt install -y python3.9 python3-pip python3-venv git - ``` +Public-key authentication is recommended: -3. **Install Poetry** - ```bash - curl -sSL https://install.python-poetry.org | python3 - - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc - source ~/.bashrc - ``` +```bash +ssh-copy-id @ +ssh -o BatchMode=yes @ true +``` -### Step 2: Clone and Setup Project +## 3. Clone and install ```bash -# Clone the repository git clone https://github.com/OG-star-tech/OGScope.git cd OGScope +chmod +x scripts/bootstrap.sh +./scripts/bootstrap.sh +``` -# Install dependencies -poetry install +`bootstrap.sh` deploys the runtime tree to `/opt/ogscope` by default, then creates the project virtual environment, installs system dependencies, and registers `ogscope.service`. + +For mainland China mirrors: + +```bash +export OGSCOPE_MIRROR=cn +./scripts/bootstrap.sh +``` + +For the minimal runtime: + +```bash +OGSCOPE_BOOTSTRAP_MODE=min ./scripts/bootstrap.sh +``` + +When the source already resides in the target runtime directory, you can run: -# Activate virtual environment -poetry shell +```bash +./scripts/install.sh ``` -### Step 3: Configure PyCharm +## 4. Verify -1. **Open Project** - - Launch PyCharm Professional - - Open the OGScope project directory +```bash +sudo systemctl status ogscope --no-pager +curl -fsS http://127.0.0.1:8000/health +curl -fsS http://127.0.0.1:8000/api/core/v1/system/status +``` -2. **Configure File Sync** - - Go to `Tools` → `Deployment` → `Configuration` - - Add SFTP server for Raspberry Pi Zero 2W - - Configure automatic file synchronization +Open: -3. **Setup Run Configurations** - - Create local run configuration for development - - Create remote run configuration for hardware testing +- Home: `http://:8000/` +- Debug console: `http://:8000/debug` +- API documentation: `http://:8000/docs` -### Step 4: Run the Application +Camera checks: ```bash -# Local development -python -m ogscope.main +rpicam-hello --list-cameras 2>/dev/null || libcamera-hello --list-cameras +curl -fsS http://127.0.0.1:8000/api/core/v1/camera/status +``` + +`POST /api/core/v1/camera/start` succeeds only after the camera confirms both `connected=true` and `streaming=true`. -# Remote testing (on Raspberry Pi) -ssh orangepi -cd /home/pi/OGScope -poetry run python -m ogscope.main +## 5. Routine updates + +```bash +cd /opt/ogscope +./scripts/board-update.sh ``` -## 🌐 Access Web Interface +To install development dependencies: -After starting the application, access: -- Local: http://localhost:8000 -- Remote: http://orangepi.local:8000 +```bash +OGSCOPE_INSTALL_DEV=1 ./scripts/board-update.sh +``` -## 🔧 Development Workflow +## 6. Local development -1. **Local Development** - - Write code in PyCharm - - Test basic functionality locally - - Use local run configuration +```bash +poetry install +poetry run pytest -q + +cd web/spa +npm ci +npm run build +``` -2. **File Synchronization** - - Files automatically sync to Raspberry Pi - - Manual sync when needed +Changes involving the camera, GPIO, I2C, or systemd must be revalidated on real hardware. See the [Development Guide](development/README_EN.md) and `scripts/sync_dev_board.sh` for board synchronization. -3. **Hardware Testing** - - Switch to remote run configuration - - Test camera and hardware features - - Debug on actual hardware +## 7. Troubleshooting -## 📚 Next Steps +### Service does not start -- Read [Development Guide](development/README.md) -- Follow the remote workflow in [Development Guide](development/README.md) -- Explore [API Documentation](API_ARCHITECTURE.md) +```bash +sudo journalctl -u ogscope -b --no-pager -n 200 +sudo systemctl cat ogscope +``` -## 🆘 Troubleshooting +### Camera is unavailable -### Common Issues +```bash +rpicam-hello --list-cameras 2>/dev/null || libcamera-hello --list-cameras +sudo journalctl -u ogscope -b --no-pager | grep -i -E 'camera|libcamera|imx327' +``` -1. **Connection Problems** - ```bash - # Check network connectivity - ping orangepi.local - - # Verify SSH connection - ssh orangepi - ``` +Check the camera overlay, ribbon orientation, and `/boot/firmware/config.txt` before rebooting. -2. **Permission Issues** - ```bash - # Fix camera permissions - sudo usermod -a -G video pi - sudo reboot - ``` +### Network or hotspot problems -3. **Dependency Issues** - ```bash - # Reinstall dependencies - poetry install --sync - ``` +Follow the [WiFi / NetworkManager Guide](development/wifi-nm_EN.md). Do not run multiple network initialization stacks at the same time. -## 📞 Support +## 8. Next documents -- [GitHub Issues](https://github.com/OG-star-tech/OGScope/issues) -- [Discussions](https://github.com/OG-star-tech/OGScope/discussions) -- [Documentation](README.md) +- [Documentation index](README_EN.md) | [中文](README.md) +- [Development Guide](development/README_EN.md) | [中文](development/README.md) +- [Core REST v1 contract](contracts/core-rest-v1_EN.md) | [中文](contracts/core-rest-v1.md) +- [Debug console](DEBUG_CONSOLE_EN.md) | [中文](DEBUG_CONSOLE.md) +- [Issue tracker](https://github.com/OG-star-tech/OGScope/issues) diff --git a/docs/README.md b/docs/README.md index 76dd781..c5c44c7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -40,5 +40,6 @@ ### E 工具与贡献 +- [脚本编写与调用规范](development/SCRIPT_STANDARDS.md) | [English](development/SCRIPT_STANDARDS_EN.md) - [调试控制台](DEBUG_CONSOLE.md) | [English](DEBUG_CONSOLE_EN.md) - [贡献指南](../CONTRIBUTING.md) | [English](../CONTRIBUTING_EN.md) diff --git a/docs/README_EN.md b/docs/README_EN.md index cadf513..73daa52 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -40,5 +40,6 @@ English | [中文](README.md) ### E Tools and contributing +- [Script authoring and invocation standard](development/SCRIPT_STANDARDS_EN.md) | [中文](development/SCRIPT_STANDARDS.md) - [Debug console](DEBUG_CONSOLE_EN.md) | [中文](DEBUG_CONSOLE.md) - [Contributing](../CONTRIBUTING_EN.md) | [中文](../CONTRIBUTING.md) diff --git a/docs/contracts/core-rest-v1.md b/docs/contracts/core-rest-v1.md index 8677dc6..bc21a67 100644 --- a/docs/contracts/core-rest-v1.md +++ b/docs/contracts/core-rest-v1.md @@ -23,12 +23,19 @@ - `fov_estimate` - `fov_max_error` - `solve_timeout_ms` + - `solve_context`(可选传感器上下文) + - `quality.time_fresh`:可选;显式为 `false` 时不使用该时间做传感器预测 + - `quality.camera_pose_calibrated`:可选;显式为 `false` 时不使用机械轴角做光轴预测 + - 两个字段缺失时保持旧客户端行为 + - 字段为 `false` 时仅返回 `sensor_status=unavailable` 诊断,不影响星图本身的 `MATCH_FOUND` 结果 - 响应: - `success: bool` - `session_id: str` - `state: "running" | "stopped"` - `message: str` +每次从停止状态启动分析时都会生成新的 `session_id`;上层应忽略旧会话结果。 + ### 2) Get Analysis Result - `GET /api/core/v1/analysis/result` @@ -69,12 +76,20 @@ - `GET /api/core/v1/camera/status` - 返回相机连接状态、流状态、runtime overrides 与可选 `ambient_hint` - `ambient_hint` 是环境亮度建议遥测,供上层设备做显示/交互策略参考;典型字段包括 `available`、`dark_score`(0.0 明亮到 1.0 昏暗)、`lux`、`exposure_us`、`digital_gain` + - `info.optics` 是可选的产品光学描述;`lens` 保存 16mm F1.4、标称 500 万像素、M12 与红外截止滤镜等名义参数,`full_sensor_fov_deg` 保存 1920×1080 全幅光学视场,`effective_fov_deg` 保存当前采集模式经过产品标定后的有效视场。上层解算与寻星应优先使用 `effective_fov_deg`,字段缺失时再回退本地默认值 + - `info.ae_scene_mode` 与 `info.ae_requested_exposure_mode` 是自主 AE 诊断;`starfield` 表示 OGScope 已独立识别暗天空并选择快门优先的长曝光曲线,不依赖上位机工作模式 - `POST /api/core/v1/camera/start` + - 仅当相机启动命令成功且状态确认 `connected=true`、`streaming=true` 时返回 `success=true` + - `applied` 包含 `action`、`hardware_plane_ok`、`ready`、`connected`、`streaming`;调用方应以 `ready` 判断是否可立即取帧 - `POST /api/core/v1/camera/stop` +- `GET /api/core/v1/camera/preview/stream?quality=75` + - 产品级 MJPEG 连续预览;使用与相机分析共享的预览消费者和并发限制 + - `quality` 范围为 `10`–`100`;省略时使用服务端预览质量配置 + - 响应禁止缓存;达到并发上限时返回 `503` -MJPEG 连续视频流与流控状态、单帧 JPEG 预览(轮询、`since_frame_id`、调试限频)**仅**暴露于开发路径(不再在 `/api/core/v1/` 重复): +流控诊断状态和单帧 JPEG 预览(轮询、`since_frame_id`、调试限频)仍仅暴露于开发路径: -- `GET /api/dev/debug/camera/stream?quality=75` — MJPEG 压缩流(JPEG) +- `GET /api/dev/debug/camera/stream?quality=75` — 与 Core 预览共享实现的开发入口 - `GET /api/dev/debug/camera/stream/status` — `max_clients`、`active_clients`、取帧超时、目标预览帧率 - `GET /api/dev/debug/camera/preview` — 单帧预览 diff --git a/docs/contracts/core-rest-v1_EN.md b/docs/contracts/core-rest-v1_EN.md index cacae6b..5923fca 100644 --- a/docs/contracts/core-rest-v1_EN.md +++ b/docs/contracts/core-rest-v1_EN.md @@ -23,12 +23,20 @@ This document defines the **minimal stable REST surface** for callers integratin - `fov_estimate` - `fov_max_error` - `solve_timeout_ms` + - `solve_context` (optional sensor context) + - `quality.time_fresh`: optional; when explicitly `false`, time is not used for sensor prediction + - `quality.camera_pose_calibrated`: optional; when explicitly `false`, mount angles are not used as camera-pose prediction + - Omitting both fields preserves legacy-client behavior + - When either field is `false`, OGScope returns `sensor_status=unavailable` for diagnostics without changing the plate solver's `MATCH_FOUND` result - Response: - `success: bool` - `session_id: str` - `state: "running" | "stopped"` - `message: str` +A new `session_id` is generated whenever analysis starts from the stopped state; +upstream consumers should ignore results from an older session. + ### 2) Get Analysis Result - `GET /api/core/v1/analysis/result` @@ -68,12 +76,20 @@ This document defines the **minimal stable REST surface** for callers integratin - `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` + - Optional `info.optics` describes product optics. `lens` carries nominal 16mm F1.4, 5MP optical rating, M12, and IR-cut properties; `full_sensor_fov_deg` describes the 1920×1080 optical field, while `effective_fov_deg` is the product-calibrated field for the active capture mode. Upstream solving and sky search should prefer `effective_fov_deg`, with a local fallback for older servers. + - `info.ae_scene_mode` and `info.ae_requested_exposure_mode` diagnose autonomous AE. `starfield` means OGScope independently selected the shutter-first long-exposure curve and does not depend on an upstream work mode. - `POST /api/core/v1/camera/start` + - Returns `success=true` only when the start command succeeds and status confirms both `connected=true` and `streaming=true` + - `applied` includes `action`, `hardware_plane_ok`, `ready`, `connected`, and `streaming`; callers should use `ready` before requesting frames - `POST /api/core/v1/camera/stop` +- `GET /api/core/v1/camera/preview/stream?quality=75` + - Product MJPEG preview using the shared preview consumer and concurrency limiter + - `quality` ranges from `10` to `100`; omission uses the server preview-quality setting + - Responses are non-cacheable; the endpoint returns `503` when the client limit is reached -MJPEG stream, stream control status, and single-frame JPEG preview (polling, `since_frame_id`, debug rate limits) are **only** on developer paths (not duplicated under `/api/core/v1/`): +Stream diagnostics and single-frame JPEG preview (polling, `since_frame_id`, debug rate limits) remain developer-only: -- `GET /api/dev/debug/camera/stream?quality=75` — MJPEG (JPEG) +- `GET /api/dev/debug/camera/stream?quality=75` — developer entry backed by the shared Core preview implementation - `GET /api/dev/debug/camera/stream/status` — `max_clients`, `active_clients`, grab timeout, target preview FPS - `GET /api/dev/debug/camera/preview` — single-frame preview diff --git a/docs/contracts/dev-rest-v1.md b/docs/contracts/dev-rest-v1.md index 1cdfa28..7072cf7 100644 --- a/docs/contracts/dev-rest-v1.md +++ b/docs/contracts/dev-rest-v1.md @@ -42,9 +42,37 @@ - `aeFlickerMode` - `noiseReductionMode` - `previewEncoder` + - 开发 UI 的曝光输入与普通摘要统一显示为秒;提交到现有 API、预设和侧车时仍换算并保留 `exposure_us` / `autoExposureMaxUs` 微秒字段,避免破坏兼容性。 + +### 星点焦点校准 + +- `POST /api/dev/debug/camera/focus/session/start` + - 暂存当前相机曝光、增益和降噪设置,锁定当前实测曝光/增益并关闭降噪,使同一轮焦点趋势可比较。 +- `POST /api/dev/debug/camera/focus/session/stop` + - 恢复开始校准前的相机设置;重复停止安全返回未激活状态。 +- `GET /api/dev/debug/camera/focus/metrics` + - 用途:基于当前原始相机帧计算实时星点锐度,引导用户手动调整镜头焦距。 + - 可选查询参数:`target_x` 与 `target_y`,均为 `0..1` 归一化坐标;必须成对提供,用于锁定预览画面中点击的星点。 + - 响应包含: + - `aggregate.median_hfd_px` / `aggregate.hfd_mad_px`:可用星点的 HFD 中位数与离散度 + - `aggregate.median_fwhm_px`:可用星点的 FWHM 中位数 + - `aggregate.median_concentration`:星点核心能量集中度 + - `stars` / `selected_star`:候选星点及当前选择 + - `stars_detected` / `stars_measured` / `stars_used`:检测、测量与质量筛选计数 + - `detection`:局部背景/噪声阈值、点击目标是否强制测量,以及分阶段淘汰计数 + - `undersampled` / `peak_snr` / `aperture_radius_px`:小星点采样状态和自适应测光信息 + - 自动候选使用焦点专用局部背景与噪声 σ 提取,不执行会删除单像素星点的形态学开运算;点击目标会在点击位置附近直接搜索局部峰值,不受全局候选数量限制。 + - HFD 与 FWHM 越低通常表示越锐利;这些数值只适合在同一镜头、曝光、增益与目标区域下做相对比较,不是跨设备绝对标定值。 ## 分析实验扩展 +### 解算管线边界 + +- `/api/core/v1/analysis/*` 与 `/api/dev/analysis/*` 保留不同的产品/实验接口职责,但两者的当前帧星图解算统一使用 `PlateSolver.solve_from_bgr_frame()` 的 Tetra 提星与匹配管线。 +- 开发接口可覆盖提星参数和实验分档;Core 使用服务端生产默认值,不向上层暴露实验分档。 +- `StarExtractor` 仍用于焦点校准、轻量星点计数和性能基线,不作为产品级 plate solve 的权威提星结果。 +- 历史的 Core `StarExtractor -> PlateSolver.solve()` 组合已停止使用;这是内部实现迁移,不弃用任何 `/api/core/v1/*` 或 `/api/dev/*` HTTP 路径。 + - `POST /api/dev/analysis/solve/frame` - `POST /api/dev/analysis/solve/frame_upload` - 请求可选 `enable_polar_guide`。 diff --git a/docs/contracts/dev-rest-v1_EN.md b/docs/contracts/dev-rest-v1_EN.md index 1bf304a..2dacb90 100644 --- a/docs/contracts/dev-rest-v1_EN.md +++ b/docs/contracts/dev-rest-v1_EN.md @@ -43,6 +43,19 @@ This document describes OGScope **developer-domain** APIs (internal). They are * - `noiseReductionMode` - `previewEncoder` +### Star focus calibration + +- `GET /api/dev/debug/camera/focus/metrics` + - Purpose: measure star sharpness from the current raw camera frame and guide manual lens focusing. + - Optional query parameters: `target_x` and `target_y`, both normalized to `0..1`; they must be supplied together and select the star nearest the clicked preview position. + - The response includes: + - `aggregate.median_hfd_px` / `aggregate.hfd_mad_px`: median HFD and dispersion across usable stars + - `aggregate.median_fwhm_px`: median FWHM across usable stars + - `aggregate.median_concentration`: median core-energy concentration + - `stars` / `selected_star`: candidate stars and the current selection + - `stars_detected` / `stars_measured` / `stars_used`: detection, measurement, and quality-filter counts + - Lower HFD and FWHM normally mean sharper focus. Treat the values as relative measurements within the same lens, exposure, gain, and target region, not as an absolute cross-device calibration. + ## Analysis lab extensions - `POST /api/dev/analysis/solve/frame` diff --git a/docs/development/README.md b/docs/development/README.md index e865e04..229d556 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -12,7 +12,7 @@ - **B 板上运维**:[WiFi](wifi-nm.md) | [English](wifi-nm_EN.md);[星库](plate-solve-data.md);[稳定性](ogscope-service-hardening.md);[BOM](../hardware/bom.md) | [English](../hardware/bom_EN.md) - **C API/契约/测试**:[API 架构(含 FastAPI 入口)](../API_ARCHITECTURE.md) | [English](../API_ARCHITECTURE_EN.md);[系统架构](../architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md);[Core 契约](../contracts/core-rest-v1.md) | [English](../contracts/core-rest-v1_EN.md);[Dev 契约](../contracts/dev-rest-v1.md) | [English](../contracts/dev-rest-v1_EN.md);[兼容矩阵](../contracts/core-compatibility-matrix.md);[自检](ARCHITECTURE_QUICK_CHECKLIST.md) | [English](ARCHITECTURE_QUICK_CHECKLIST_EN.md);[测试](testing-guide.md) | [English](testing-guide_EN.md) - **D 外部集成**:[Subordinate 模式](../contracts/subordinate-mode.md) | [English](../contracts/subordinate-mode_EN.md);[Hardware Plane UDS v1](../contracts/hardware-plane-uds-v1.md) | [English](../contracts/hardware-plane-uds-v1_EN.md) -- **E 工具与贡献**:[脚本规范](SCRIPT_STANDARDS.md);[调试控制台](../DEBUG_CONSOLE.md) | [English](../DEBUG_CONSOLE_EN.md);[贡献指南](../../CONTRIBUTING.md) | [English](../../CONTRIBUTING_EN.md) +- **E 工具与贡献**:[脚本规范](SCRIPT_STANDARDS.md) | [English](SCRIPT_STANDARDS_EN.md);[调试控制台](../DEBUG_CONSOLE.md) | [English](../DEBUG_CONSOLE_EN.md);[贡献指南](../../CONTRIBUTING.md) | [English](../../CONTRIBUTING_EN.md) 当前推荐流程为:**本地编辑代码 -> 上传到开发板 -> 使用 `systemd` 重启服务验证**。 该流程与实际硬件运行环境一致,适合涉及相机与系统库依赖的场景。 diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md index 17e92e5..680ee84 100644 --- a/docs/development/README_EN.md +++ b/docs/development/README_EN.md @@ -14,7 +14,7 @@ and the team-standard debug workflow. - **B Board ops**: [WiFi](wifi-nm_EN.md) | [中文](wifi-nm.md); [plate-solve data](plate-solve-data.md); [stability / memory](ogscope-service-hardening.md); [BOM](../hardware/bom_EN.md) | [中文](../hardware/bom.md) - **C API and quality**: [API architecture (includes FastAPI entry)](../API_ARCHITECTURE_EN.md) | [中文](../API_ARCHITECTURE.md); [system architecture](../architecture/OGSCOPE_SYSTEM_ARCHITECTURE_BILINGUAL.md); [Core contract](../contracts/core-rest-v1_EN.md) | [中文](../contracts/core-rest-v1.md); [Dev contract](../contracts/dev-rest-v1_EN.md) | [中文](../contracts/dev-rest-v1.md); [compatibility matrix](../contracts/core-compatibility-matrix.md); [checklist](ARCHITECTURE_QUICK_CHECKLIST_EN.md) | [中文](ARCHITECTURE_QUICK_CHECKLIST.md); [testing](testing-guide_EN.md) | [中文](testing-guide.md) - **D External integration**: [Subordinate mode](../contracts/subordinate-mode_EN.md) | [中文](../contracts/subordinate-mode.md); [Hardware Plane UDS v1](../contracts/hardware-plane-uds-v1_EN.md) | [中文](../contracts/hardware-plane-uds-v1.md) -- **E Tools and contributing**: [Debug console](../DEBUG_CONSOLE_EN.md) | [中文](../DEBUG_CONSOLE.md); [Contributing](../../CONTRIBUTING_EN.md) | [中文](../../CONTRIBUTING.md) +- **E Tools and contributing**: [Script standard](SCRIPT_STANDARDS_EN.md) | [中文](SCRIPT_STANDARDS.md); [Debug console](../DEBUG_CONSOLE_EN.md) | [中文](../DEBUG_CONSOLE.md); [Contributing](../../CONTRIBUTING_EN.md) | [中文](../../CONTRIBUTING.md) Recommended workflow: **edit locally -> upload to board -> restart with `systemd` -> verify**. diff --git a/docs/development/SCRIPT_STANDARDS.md b/docs/development/SCRIPT_STANDARDS.md index 79d7c34..db4cb92 100644 --- a/docs/development/SCRIPT_STANDARDS.md +++ b/docs/development/SCRIPT_STANDARDS.md @@ -1,6 +1,6 @@ # OGScope 脚本编写与调用规范 -中文 | English (inline) +中文 | [English](SCRIPT_STANDARDS_EN.md) 本规范用于统一 `scripts/` 与后续 `deploy/` 相关脚本的行为,降低双仓联动时的维护成本与误操作风险。 @@ -98,4 +98,3 @@ - 涉及运行路径改动时,验证 `systemd` 与健康检查可用: - `systemctl status ogscope` - `curl -s http://127.0.0.1:8000/health` - diff --git a/docs/development/SCRIPT_STANDARDS_EN.md b/docs/development/SCRIPT_STANDARDS_EN.md new file mode 100644 index 0000000..85040e3 --- /dev/null +++ b/docs/development/SCRIPT_STANDARDS_EN.md @@ -0,0 +1,93 @@ +# OGScope Script Authoring and Invocation Standard + +English | [中文](SCRIPT_STANDARDS.md) + +This standard keeps `scripts/` and future `deploy/` helpers consistent, reducing maintenance cost and operational risk for external integrations. + +## 1. Scope and boundaries + +- OGScope scripts manage only OGScope installation, updates, removal, diagnostics, and repair. +- Do not embed deployment logic for a specific downstream product. +- External integrators own combined-stack orchestration; OGScope exposes stable script entry points and health checks. + +## 2. Unified command model + +Recommended future entry point: + +```bash +./scripts/stackctl.sh [options] +``` + +Standard commands: + +- `install`: first installation, including minimal and extended modes +- `update`: incremental update +- `uninstall`: removal +- `doctor`: read-only diagnostics +- `repair`: allowlisted repairs +- `status`: current runtime and version summary + +## 3. Arguments + +Scripts should support these common arguments where applicable: + +- `--yes`: non-interactive confirmation for CI and automation +- `--dry-run`: inspect without writing files or restarting services +- `--json`: machine-readable output +- `--verbose`: detailed progress logs +- `--strict`: treat warnings as failures + +Invalid arguments must return the argument-error exit code. Destructive actions require explicit confirmation unless `--yes` is supplied. + +## 4. Exit codes + +- `0`: success +- `2`: invalid arguments +- `3`: preconditions not met, such as dependencies, permissions, or environment +- `4`: execution failed +- `5`: partial success with incomplete actions + +## 5. Idempotency and safety + +- Scripts must be idempotent and must not damage an already working installation when rerun. +- Changes to `systemd`, `/etc/*`, or networking must identify the target, support a dry run, and provide a recovery path on failure. +- Never print passwords, tokens, private keys, or other secrets to logs. + +## 6. Output and logging + +- Human-readable critical messages should be bilingual. +- JSON output must include at least `success`, `code`, `summary`, and `checks` or `actions`. +- Use stable `INFO`, `WARN`, and `ERROR` prefixes for important steps. + +## 7. Files and naming + +- Use `kebab-case` script names. +- Put entry scripts in `scripts/` and reusable functions in `scripts/lib/`. +- Prefer shared functions over duplicated system operations. + +## 8. Privilege handling + +- Check privileges when the top-level script starts; do not elevate unexpectedly in the middle of a workflow. +- Group root operations and explain them before execution. +- Handle `sudo -n` failures with a clear message and a manual recovery command. + +## 9. External integration + +OGScope remains a core capability provider. It guarantees: + +- stable install, update, and uninstall entry points; +- diagnosable health and configuration state; +- the `core/v1` and [subordinate mode](../contracts/subordinate-mode_EN.md) contracts. + +Combined deployment and product orchestration belong to the external integrator. + +## 10. Minimum checks before committing script changes + +- Run `bash -n` for every changed shell script. +- Run the target script with `--help` or an equivalent argument check. +- When runtime paths change, validate: + +```bash +systemctl status ogscope +curl -s http://127.0.0.1:8000/health +``` diff --git a/docs/development/ogscope-service-hardening.md b/docs/development/ogscope-service-hardening.md index 8f4b977..8189659 100644 --- a/docs/development/ogscope-service-hardening.md +++ b/docs/development/ogscope-service-hardening.md @@ -32,5 +32,14 @@ sudo dmesg -T | grep -i -E 'oom|killed process' |-----------------|----------------| | `OGSCOPE_PREVIEW_JPEG_QUALITY` | 共享预览 JPEG 质量(与调试 MJPEG 默认质量一致)/ Shared preview JPEG quality | | `OGSCOPE_SHARED_PREVIEW_FPS` | 共享抓帧与 MJPEG 推送目标帧率 / Shared grabber and MJPEG pacing FPS | +| `OGSCOPE_STREAM_MJPEG_CLIENT_STALL_TIMEOUT_MS` | 下游 MJPEG 发送无进展超时,0 表示禁用 / Downstream MJPEG send-stall timeout; 0 disables it | | `OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS` | 调试「单帧预览」接口每客户端最小间隔(毫秒);过短返回 304 / Min interval for `/api/dev/debug/camera/preview` per client | +| `OGSCOPE_CAMERA_CAPTURE_TIMEOUT_SEC` | 单次 Picamera2 抓帧的硬超时,必须高于最长曝光;超时后复用未完成任务,不重复排队新请求 / Hard timeout for one Picamera2 capture; keep above maximum exposure and do not queue duplicate jobs after timeout | | `OGSCOPE_KEEP_RAW_CACHE` | `1` 时在共享管理器中常驻 `_latest_raw`;默认 `0` 以省内存 / Retain raw frame cache when `1` | + +当抓帧线程、`stop` 或 `close` 无法在限时内结束时,CameraManager 会保留原相机句柄并报告 +`restart_required=true`,禁止同一进程创建第二个 Picamera2 实例。此状态需要重启 `ogscope.service` +来释放 libcamera 资源。/ When a capture worker, `stop`, or `close` cannot finish within +its deadline, CameraManager retains the original handle, reports `restart_required=true`, and +blocks a second Picamera2 instance in the same process. Restart `ogscope.service` to release the +libcamera resources. diff --git a/docs/hardware/bom.md b/docs/hardware/bom.md index f79b0b4..a216739 100644 --- a/docs/hardware/bom.md +++ b/docs/hardware/bom.md @@ -27,8 +27,10 @@ ### IMX327 相机模块 - **传感器**: Sony IMX327 +- **镜头**: 16mm F1.4 低照度镜头,标称 500 万像素,M12 接口,带红外截止滤镜 - **分辨率**: 1920x1080 (2MP) - **像素大小**: 2.9μm x 2.9μm +- **视场**: 1920×1080 全幅名义视场约 19.74° × 11.18°;产品默认 1280×720 模式的标定有效视场约 13.01° × 7.34° - **接口**: MIPI CSI - **灵敏度**: 高感光度,适合极轴镜 - **购买渠道**: 淘宝/AliExpress @@ -109,4 +111,3 @@ --- 最后更新: 2025-01-01 - diff --git a/docs/hardware/bom_EN.md b/docs/hardware/bom_EN.md index 8d51d36..98bcaf9 100644 --- a/docs/hardware/bom_EN.md +++ b/docs/hardware/bom_EN.md @@ -27,8 +27,10 @@ English | [中文](bom.md) ### IMX327 module - **Sensor**: Sony IMX327 +- **Lens**: 16mm F1.4 low-light lens, 5MP optical rating, M12 mount, with IR-cut filter - **Resolution**: 1920×1080 (2MP) -- **Pixel**: 2.9 µm +- **Pixel**: 2.9 µm × 2.9 µm +- **Field of view**: about 19.74° × 11.18° nominal at 1920×1080; about 13.01° × 7.34° product-calibrated for the default 1280×720 mode - **Interface**: MIPI CSI - **Sensitivity**: suited for polar scope use - **Sourcing**: Taobao / AliExpress diff --git a/ogscope/algorithms/plate_solve/sensor_context.py b/ogscope/algorithms/plate_solve/sensor_context.py index c86adab..45e9f6a 100644 --- a/ogscope/algorithms/plate_solve/sensor_context.py +++ b/ogscope/algorithms/plate_solve/sensor_context.py @@ -146,10 +146,28 @@ def predict_from_solve_context( if not ctx: return {"sensor_status": "unavailable"} + # A raw motor position is not an absolute camera pointing direction until + # the mechanical zero and boresight transform are calibrated. + # 未标定机械零位与光轴变换前,原始电机角度不是摄像头的绝对指向。 + if quality.get("camera_pose_calibrated") is False: + return { + "sensor_status": "unavailable", + "sensor_reason": "camera_pose_uncalibrated", + } + if quality.get("time_fresh") is False: + return { + "sensor_status": "unavailable", + "sensor_reason": "observer_time_stale", + } + gps_valid = bool(quality.get("gps_valid")) time_valid = bool(quality.get("time_valid")) + time_fresh_raw = quality.get("time_fresh") + time_fresh = time_valid if time_fresh_raw is None else bool(time_fresh_raw) mount_valid = bool(quality.get("mount_valid")) heading_valid = bool(quality.get("heading_valid")) + camera_pose_raw = quality.get("camera_pose_calibrated") + camera_pose_calibrated = True if camera_pose_raw is None else bool(camera_pose_raw) lat = _optional_float(observer.get("latitude_deg")) lon = _optional_float(observer.get("longitude_deg")) when = _parse_utc(observer.get("time_utc")) @@ -160,17 +178,28 @@ def predict_from_solve_context( if ( not gps_valid or not time_valid + or not time_fresh + or not camera_pose_calibrated or lat is None or lon is None or when is None or alt is None or az is None ): - return {"sensor_status": "unavailable"} + return { + "sensor_status": "unavailable", + "sensor_reason": "sensor_context_incomplete", + } if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 and -90.0 <= alt <= 90.0): - return {"sensor_status": "unavailable"} + return { + "sensor_status": "unavailable", + "sensor_reason": "sensor_context_invalid", + } if not (mount_valid or heading_valid): - return {"sensor_status": "unavailable"} + return { + "sensor_status": "unavailable", + "sensor_reason": "orientation_unavailable", + } predicted_ra, predicted_dec = horizontal_to_equatorial( altitude_deg=alt, azimuth_deg=az, diff --git a/ogscope/camera_optics.py b/ogscope/camera_optics.py new file mode 100644 index 0000000..970d2f9 --- /dev/null +++ b/ogscope/camera_optics.py @@ -0,0 +1,150 @@ +"""OGScope 产品相机光学模型 / OGScope product camera optics model.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True, slots=True) +class CameraOpticsProfile: + """固定硬件的名义参数与产品标定 / Nominal hardware and product calibration.""" + + profile_id: str + sensor_model: str + sensor_pixel_pitch_um: float + sensor_recording_width_px: int + sensor_recording_height_px: int + lens_focal_length_mm: float + lens_aperture_f_number: float + lens_resolution_rating_mp: float + lens_mount: str + ir_cut_filter: bool + calibrated_focal_length_mm: float + + @staticmethod + def _axis_fov_deg( + pixels: int, pixel_pitch_um: float, focal_length_mm: float + ) -> float: + sensor_size_mm = max(1, int(pixels)) * float(pixel_pitch_um) / 1000.0 + return math.degrees(2.0 * math.atan(sensor_size_mm / (2.0 * focal_length_mm))) + + def _fov_pair( + self, width_px: int, height_px: int, *, calibrated: bool + ) -> tuple[float, float]: + focal_length_mm = ( + self.calibrated_focal_length_mm if calibrated else self.lens_focal_length_mm + ) + return ( + self._axis_fov_deg(width_px, self.sensor_pixel_pitch_um, focal_length_mm), + self._axis_fov_deg(height_px, self.sensor_pixel_pitch_um, focal_length_mm), + ) + + def describe_capture( + self, + *, + capture_width_px: int, + capture_height_px: int, + sampling_mode: str, + rotation_deg: int = 0, + ) -> dict[str, Any]: + """描述当前采集模式的真实有效视场 / Describe effective FOV for the capture mode.""" + mode = str(sampling_mode or "native").lower() + if mode == "supersample": + region_width_px = self.sensor_recording_width_px + region_height_px = self.sensor_recording_height_px + else: + region_width_px = min( + self.sensor_recording_width_px, max(1, int(capture_width_px)) + ) + region_height_px = min( + self.sensor_recording_height_px, max(1, int(capture_height_px)) + ) + + full_width_deg, full_height_deg = self._fov_pair( + self.sensor_recording_width_px, + self.sensor_recording_height_px, + calibrated=False, + ) + theoretical_width_deg, theoretical_height_deg = self._fov_pair( + region_width_px, + region_height_px, + calibrated=False, + ) + effective_width_deg, effective_height_deg = self._fov_pair( + region_width_px, + region_height_px, + calibrated=True, + ) + + rotation = int(rotation_deg) % 360 + if rotation in {90, 270}: + effective_width_deg, effective_height_deg = ( + effective_height_deg, + effective_width_deg, + ) + theoretical_width_deg, theoretical_height_deg = ( + theoretical_height_deg, + theoretical_width_deg, + ) + + return { + "profile_id": self.profile_id, + "sensor": { + "model": self.sensor_model, + "pixel_pitch_um": self.sensor_pixel_pitch_um, + "recording_width_px": self.sensor_recording_width_px, + "recording_height_px": self.sensor_recording_height_px, + }, + "lens": { + "focal_length_mm": self.lens_focal_length_mm, + "aperture_f_number": self.lens_aperture_f_number, + "resolution_rating_mp": self.lens_resolution_rating_mp, + "mount": self.lens_mount, + "ir_cut_filter": self.ir_cut_filter, + }, + "full_sensor_fov_deg": { + "width": round(full_width_deg, 2), + "height": round(full_height_deg, 2), + "source": "nominal_optical_model", + }, + "effective_sensor_region_px": { + "width": region_width_px, + "height": region_height_px, + }, + "theoretical_effective_fov_deg": { + "width": round(theoretical_width_deg, 2), + "height": round(theoretical_height_deg, 2), + }, + "effective_fov_deg": { + "width": round(effective_width_deg, 2), + "height": round(effective_height_deg, 2), + "source": "product_calibrated", + }, + "sampling_mode": mode, + "rotation_deg": rotation, + } + + +# 16 mm 是镜头名义焦距;16.277 mm 来自当前 1280x720 星图解算对 13.01° +# 水平视场的产品标定。两者分开保存,避免把名义规格误当作有效成像视场。 +# 16 mm is the nominal lens focal length; 16.277 mm is the product calibration +# derived from the solved 13.01° horizontal FOV at 1280x720. Keep both values +# so nominal optics are never confused with the effective capture FOV. +IMX327_16MM_F14_OPTICS = CameraOpticsProfile( + profile_id="imx327-16mm-f1.4-ircut-v1", + sensor_model="IMX327", + sensor_pixel_pitch_um=2.9, + sensor_recording_width_px=1920, + sensor_recording_height_px=1080, + lens_focal_length_mm=16.0, + lens_aperture_f_number=1.4, + lens_resolution_rating_mp=5.0, + lens_mount="M12", + ir_cut_filter=True, + calibrated_focal_length_mm=16.277273749463617, +) + +DEFAULT_EFFECTIVE_FOV_WIDTH_DEG = 13.01 +DEFAULT_EFFECTIVE_FOV_HEIGHT_DEG = 7.34 diff --git a/ogscope/config.py b/ogscope/config.py index 9285ddc..dca49c8 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -9,6 +9,8 @@ from pydantic import Field, field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict +from ogscope.camera_optics import DEFAULT_EFFECTIVE_FOV_WIDTH_DEG + class Settings(BaseSettings): """应用配置 / Application configuration""" @@ -102,19 +104,33 @@ class Settings(BaseSettings): camera_gain: float = Field(default=1.0, description="增益") camera_ae_polar_preset: bool = Field( default=True, - description="自动曝光时启用电子极轴镜 AE 预设 (Shadows/Matrix/Long+EV) / AE polar-scope preset", + description=( + "启用 OGScope 自主星空场景 AE (Shadows/Matrix/Long+EV) / " + "Enable OGScope autonomous starfield AE" + ), ) camera_ae_exposure_value: float = Field( - default=0.35, + default=1.0, ge=-2.0, le=2.0, - description="AE 曝光补偿(档),与 camera_ae_polar_preset 联用 / AE exposure comp EV stops", + description="星空 AE 曝光补偿(档) / Starfield AE exposure compensation in EV stops", + ) + camera_ae_aggressive_enabled: bool = Field( + default=True, + description="根据暗部与高光占比动态提高极轴镜 AE / Dynamically bias polar AE toward dark detail", + ) + camera_tuning_file: Optional[Path] = Field( + default=None, + description=( + "Picamera2 tuning 覆盖路径;为空时使用随产品发布的 IMX327 tuning / " + "Picamera2 tuning override path; bundled IMX327 tuning is used by default" + ), ) camera_auto_exposure_max_us: int = Field( - default=2_000_000, + default=1_000_000, ge=10_000, le=10_000_000, - description="自动曝光最长帧周期 us,暗场允许降帧 / Max auto-exposure frame duration in us", + description="自动曝光最长帧周期 1s,暗场允许降帧 / Max auto-exposure frame duration, capped at 1s", ) camera_ae_flicker_mode: str = Field( default="off", @@ -229,7 +245,11 @@ class Settings(BaseSettings): solver_hint_ra_deg: float = Field(default=0.0, description="默认解算RA提示(度)") solver_hint_dec_deg: float = Field(default=90.0, description="默认解算Dec提示(度)") solver_fov_deg: float = Field( - default=11.0, description="视场角(度) / Default FOV estimate (deg)" + default=DEFAULT_EFFECTIVE_FOV_WIDTH_DEG, + description=( + "当前 IMX327 1280x720 + 16mm 镜头的有效水平视场(度) / " + "Effective horizontal FOV for the current IMX327 1280x720 + 16mm capture" + ), ) solver_max_stars: int = Field(default=80, description="用于解算的最大星点数量") solver_fullsolve_interval_frames: int = Field( @@ -338,9 +358,18 @@ class Settings(BaseSettings): ge=3000, le=120000, description=( - "MJPEG 循环单次取帧(含编码线程)最大等待毫秒;超时则结束流并释放名额," - "避免异常断开时长时间占满并发 / Max wait per MJPEG frame fetch (incl. encode thread); " - "on timeout the stream ends to free slots after abnormal client disconnect" + "MJPEG 循环单次取帧(含编码线程)最大等待毫秒;超时表示相机取帧停滞 / " + "Max wait per MJPEG frame fetch (incl. encode thread); timeout means camera fetch stalled" + ), + ) + stream_mjpeg_client_stall_timeout_ms: int = Field( + default=30000, + ge=0, + le=300000, + description=( + "MJPEG 下游发送无进展的最大毫秒数;至少比取帧超时多 5 秒,0=禁用 / " + "Max time without downstream MJPEG send progress; effectively at least 5s above " + "frame-fetch timeout; 0=disabled" ), ) @@ -375,6 +404,15 @@ class Settings(BaseSettings): le=30.0, description="相机探测超时(秒)/ Camera probe timeout in seconds", ) + camera_capture_timeout_sec: float = Field( + default=8.0, + ge=0.5, + le=120.0, + description=( + "单次相机抓帧硬超时(秒);1 秒 AE 首帧需要包含多帧收敛预算 / " + "Hard frame timeout; one-second AE startup needs a multi-frame convergence budget" + ), + ) camera_grab_failures_offline: int = Field( default=3, ge=1, @@ -524,6 +562,15 @@ def _parse_camera_white_balance_mode(cls, value: object) -> str: return text return "auto" + @field_validator("camera_auto_exposure_max_us", mode="before") + @classmethod + def _cap_camera_auto_exposure_max_us(cls, value: object) -> object: + """兼容旧配置并限制暗场曝光为 1s / Keep legacy config bootable and cap AE at 1s.""" + try: + return min(1_000_000, int(value)) + except (TypeError, ValueError): + return value + @field_validator("camera_ae_flicker_mode", mode="before") @classmethod def _parse_camera_ae_flicker_mode(cls, value: object) -> str: diff --git a/ogscope/config_catalog.py b/ogscope/config_catalog.py index 5f51249..aa0f8ab 100644 --- a/ogscope/config_catalog.py +++ b/ogscope/config_catalog.py @@ -68,6 +68,8 @@ "camera_gain", "camera_ae_polar_preset", "camera_ae_exposure_value", + "camera_ae_aggressive_enabled", + "camera_tuning_file", "camera_auto_exposure_max_us", "camera_ae_flicker_mode", "camera_noise_reduction_mode", @@ -94,12 +96,14 @@ "preview_encoder", "debug_preview_min_interval_ms", "camera_probe_timeout_sec", + "camera_capture_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", + "stream_mjpeg_client_stall_timeout_ms", ), ), ( diff --git a/ogscope/core/application/core_service.py b/ogscope/core/application/core_service.py index bd3db01..8cdab0d 100644 --- a/ogscope/core/application/core_service.py +++ b/ogscope/core/application/core_service.py @@ -5,6 +5,7 @@ from __future__ import annotations import math +import uuid from dataclasses import dataclass from typing import Any @@ -190,6 +191,8 @@ async def start_analysis( solve_context: Any | None = None, ) -> dict[str, Any]: """开始实时分析 / Start realtime analysis.""" + was_running = self._session.running + next_session_id = self._session.session_id if was_running else uuid.uuid4().hex result = await realtime_solve_service.start( hint_ra_deg=hint_ra_deg, hint_dec_deg=hint_dec_deg, @@ -197,10 +200,16 @@ async def start_analysis( fov_max_error=fov_max_error, solve_timeout_ms=solve_timeout_ms, solve_context=solve_context, + session_id=next_session_id, ) - self._session.running = True + success = bool(result.get("success", True)) + self._session.running = success + if success and not was_running: + # A unique id lets upstream consumers reject results from an older run. + # 唯一会话 ID 让上层能够拒绝旧一轮分析留下的结果。 + self._session.session_id = str(result.get("session_id") or next_session_id) return { - "success": bool(result.get("success", True)), + "success": success, "session_id": self._session.session_id, "state": "running", "message": result.get("message", ""), @@ -327,23 +336,37 @@ async def get_camera_status(self) -> dict[str, Any]: async def start_camera(self) -> dict[str, Any]: """启动 Core 相机 / Start core camera.""" hardware_client = get_hardware_plane_client() - hp_result = await hardware_client.device_command("camera", "start") + # 冷启动可能超过硬件平面通用 RPC 预算;先完成真实相机启动,再同步平面状态。 + # Cold start can exceed the generic plane RPC budget; start hardware first, then sync plane state. result = await camera_domain_service.start() + hp_result = await hardware_client.device_command("camera", "start") + status = await self.get_camera_status() + ready = bool(status.get("connected")) and bool(status.get("streaming")) + message = str(result.get("message", "")) + if not ready: + message = str( + status.get("error") or message or "camera did not become ready" + ) return { - "success": bool(result.get("success", True)), - "message": result.get("message", ""), - "info": {}, + "success": bool(result.get("success", True)) and ready, + "message": message, + "info": status.get("info", {}), "applied": { "action": "start", "hardware_plane_ok": bool(hp_result.get("success", False)), + "ready": ready, + "connected": bool(status.get("connected")), + "streaming": bool(status.get("streaming")), }, } async def stop_camera(self) -> dict[str, Any]: """停止 Core 相机 / Stop core camera.""" hardware_client = get_hardware_plane_client() - hp_result = await hardware_client.device_command("camera", "stop") + # 先让相机域完成有界清理,避免硬件平面短 RPC 预算提前取消释放流程。 + # Let the camera domain finish bounded cleanup before the short plane RPC can cancel it. result = await camera_domain_service.stop() + hp_result = await hardware_client.device_command("camera", "stop") return { "success": bool(result.get("success", True)), "message": result.get("message", ""), @@ -358,15 +381,25 @@ async def tune_camera(self, payload: dict[str, Any]) -> dict[str, Any]: """按 Core 语义微调相机参数 / Tune camera params with core semantics.""" applied: dict[str, Any] = {} auto_exposure = payload.get("auto_exposure") - if auto_exposure is not None: + exposure_us = payload.get("exposure_us") + restore_auto_after_exposure = bool(auto_exposure) and exposure_us is not None + + if auto_exposure is not None and not restore_auto_after_exposure: await camera_domain_service.set_auto_exposure_mode(bool(auto_exposure)) applied["auto_exposure"] = bool(auto_exposure) - if payload.get("exposure_us") is not None: - await camera_domain_service.update_settings( - {"exposure": payload["exposure_us"]} - ) - applied["exposure_us"] = int(payload["exposure_us"]) + if exposure_us is not None: + exposure_settings: dict[str, Any] = {"exposure": exposure_us} + if restore_auto_after_exposure: + # 先在手动模式写入可恢复基线,再重新开启 AE。 + # Write the restorable manual baseline before re-enabling AE. + exposure_settings["autoExposure"] = False + await camera_domain_service.update_settings(exposure_settings) + applied["exposure_us"] = int(exposure_us) + + if restore_auto_after_exposure: + await camera_domain_service.set_auto_exposure_mode(True) + applied["auto_exposure"] = True if payload.get("analogue_gain") is not None: settings: dict[str, Any] = {"gain": float(payload["analogue_gain"])} diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py index 61268b1..4d1db96 100644 --- a/ogscope/core/realtime/service.py +++ b/ogscope/core/realtime/service.py @@ -5,13 +5,15 @@ from __future__ import annotations import asyncio +import json import time from dataclasses import dataclass from typing import Any +from loguru import logger + 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 @@ -25,6 +27,8 @@ class RealtimeState: fullsolve_count: int = 0 last_result: dict[str, Any] | None = None last_error: str = "" + session_id: str = "" + started_mono: float = 0.0 class RealtimeSolveService: @@ -32,7 +36,7 @@ class RealtimeSolveService: def __init__(self) -> None: settings = get_settings() - self.extractor = StarExtractor(max_stars=effective_solver_max_stars(settings)) + self._max_stars = effective_solver_max_stars(settings) self.solver = PlateSolver( fov_deg=settings.solver_fov_deg, fov_max_error_deg=settings.solver_fov_max_error_deg, @@ -40,7 +44,7 @@ def __init__(self) -> None: ) self.state = RealtimeState() self._task: asyncio.Task[None] | None = None - self._previous_stars: list[StarPoint] | None = None + self._has_fullsolve = False self._hint_ra = settings.solver_hint_ra_deg self._hint_dec = settings.solver_hint_dec_deg self._fullsolve_interval = max(1, settings.solver_fullsolve_interval_frames) @@ -61,11 +65,13 @@ async def start( fov_max_error: float | None = None, solve_timeout_ms: int | None = None, solve_context: Any | None = None, + session_id: str | None = None, ) -> dict[str, Any]: """启动实时解算 / Start realtime solving""" if self.state.running: return { "success": True, + "session_id": self.state.session_id, "message": "实时解算已在运行 / Realtime solver already running", } if hint_ra_deg is not None: @@ -76,13 +82,28 @@ async def start( 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.state = RealtimeState( + running=True, + session_id=str(session_id or ""), + started_mono=time.monotonic(), + ) + self._has_fullsolve = False self._task = asyncio.create_task(self._loop()) - return {"success": True, "message": "实时解算已启动 / Realtime solver started"} + self._log_event( + "session_started", + fov_estimate=fov_estimate, + fov_max_error=fov_max_error, + solve_timeout_ms=solve_timeout_ms, + ) + return { + "success": True, + "session_id": self.state.session_id, + "message": "实时解算已启动 / Realtime solver started", + } async def stop(self) -> dict[str, Any]: """停止实时解算 / Stop realtime solving""" + was_running = self.state.running self.state.running = False if self._task and not self._task.done(): self._task.cancel() @@ -91,6 +112,14 @@ async def stop(self) -> dict[str, Any]: except asyncio.CancelledError: pass self._task = None + if was_running: + self._log_event( + "session_stopped", + frame_count=self.state.frame_count, + fullsolve_count=self.state.fullsolve_count, + last_status=str((self.state.last_result or {}).get("status") or ""), + last_error=self.state.last_error, + ) return {"success": True, "message": "实时解算已停止 / Realtime solver stopped"} async def get_status(self) -> dict[str, Any]: @@ -101,6 +130,7 @@ async def get_status(self) -> dict[str, Any]: "fullsolve_count": self.state.fullsolve_count, "last_result": self.state.last_result, "last_error": self.state.last_error, + "session_id": self.state.session_id, } async def _loop(self) -> None: @@ -134,35 +164,70 @@ async def _loop(self) -> None: continue last_frame_id = frame_id last_started_mono = time.monotonic() - stars = self.extractor.extract(frame) self.state.frame_count += 1 use_fullsolve = ( self.state.frame_count % self._fullsolve_interval == 0 - or self._previous_stars is None + or not self._has_fullsolve + or str((self.state.last_result or {}).get("status") or "") + != "MATCH_FOUND" ) if use_fullsolve: + solve_started = time.monotonic() solved = await asyncio.to_thread( self._solve_frame_sync, frame, - stars, ) self._apply_solve_result(solved) self.state.fullsolve_count += 1 - self._previous_stars = stars + self._log_event( + "fullsolve_finished", + frame_count=self.state.frame_count, + fullsolve_count=self.state.fullsolve_count, + status=solved.status, + detected_stars=solved.detected_stars, + matches=solved.matches, + t_solve_ms=solved.t_solve_ms, + t_extract_ms=solved.t_extract_ms, + t_preprocess_ms=solved.t_preprocess_ms, + wall_ms=int((time.monotonic() - solve_started) * 1000), + ) + # ``solve_from_bgr_frame`` is the authoritative production + # image pipeline. Keep only a sentinel here; StarExtractor + # remains available for focus metrics and lightweight counts. + # ``solve_from_bgr_frame`` 是生产解算权威图像管线;这里只保留哨兵。 + # StarExtractor 仍用于焦点指标和轻量星点计数。 + self._has_fullsolve = True except Exception as exc: # noqa: BLE001 + changed = str(exc) != self.state.last_error self.state.last_error = str(exc) + if changed: + self._log_event("loop_error", error=str(exc), level="warning") await asyncio.sleep(0.1) + def _log_event(self, event: str, *, level: str = "info", **fields: Any) -> None: + """Emit one correlated solve lifecycle event / 输出一条可关联的解算生命周期事件。""" + started = self.state.started_mono + payload = { + "event": event, + "session_id": self.state.session_id, + "elapsed_ms": int((time.monotonic() - started) * 1000) if started else 0, + **fields, + } + log = logger.warning if level == "warning" else logger.info + log( + "analysis_event {}", + json.dumps(payload, ensure_ascii=False, separators=(",", ":"), default=str), + ) + def _solve_frame_sync( self, frame: Any, - stars: list[StarPoint], ) -> SolveResult: """同步解算单帧(线程池中调用)/ Sync solve for one frame.""" - return self.solver.solve( - stars=stars, - frame_shape=frame.shape, + return self.solver.solve_from_bgr_frame( + frame_bgr=frame, + max_stars=self._max_stars, hint_ra_deg=self._hint_ra, hint_dec_deg=self._hint_dec, solve_source="realtime", @@ -176,6 +241,9 @@ def _apply_solve_result(self, solved: SolveResult) -> None: row = solved.to_dict() attach_sensor_prediction(row, self._solve_context) self.state.last_result = row + # A later completed frame supersedes a transient capture/solve exception. + # 后续完成的帧应清除瞬时采集或解算异常,避免上层永久看到旧错误。 + self.state.last_error = "" self._hint_ra = solved.ra_deg self._hint_dec = solved.dec_deg diff --git a/ogscope/domain/camera/focus.py b/ogscope/domain/camera/focus.py new file mode 100644 index 0000000..182c1a6 --- /dev/null +++ b/ogscope/domain/camera/focus.py @@ -0,0 +1,473 @@ +"""星点焦点质量测量 / Star focus quality measurement.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +import cv2 +import numpy as np + + +@dataclass(slots=True) +class FocusCandidate: + """焦点测量候选亮点 / Candidate point source for focus measurement.""" + + x: float + y: float + flux: float + area: int + source: str = "auto" + + +class FocusCandidateExtractor: + """用局部背景与噪声阈值保留欠采样星点 / Keep undersampled stars with local statistics.""" + + def __init__( + self, + *, + max_stars: int = 40, + filter_size: int = 25, + threshold_sigma: float = 2.5, + max_area: int = 400, + ) -> None: + self._max_stars = max(1, int(max_stars)) + size = max(7, int(filter_size)) + self._filter_size = size if size % 2 else size + 1 + self._threshold_sigma = max(1.5, float(threshold_sigma)) + self._max_area = max(16, int(max_area)) + + def extract(self, gray: np.ndarray) -> tuple[list[FocusCandidate], dict[str, Any]]: + """提取小而亮的连通域且不做形态学开运算 / Extract compact sources without opening.""" + data = np.asarray(gray, dtype=np.float32) + background = cv2.blur( + data, + (self._filter_size, self._filter_size), + borderType=cv2.BORDER_REFLECT, + ) + residual = data - background + residual_median = float(np.median(residual)) + mad = float(np.median(np.abs(residual - residual_median))) + noise = max(0.5, 1.4826 * mad) + threshold = max(1.5, self._threshold_sigma * noise) + mask = np.asarray(residual > threshold, dtype=np.uint8) + + count, labels, stats, centroids = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + positive = np.clip(residual, 0.0, None) + flat_labels = labels.ravel() + flux_by_label = np.bincount( + flat_labels, + weights=positive.ravel(), + minlength=count, + ) + peak_by_label = np.full(count, -np.inf, dtype=np.float32) + np.maximum.at(peak_by_label, flat_labels, residual.ravel()) + candidates: list[FocusCandidate] = [] + rejected_large = 0 + rejected_weak = 0 + for label in range(1, count): + area = int(stats[label, cv2.CC_STAT_AREA]) + if area > self._max_area: + rejected_large += 1 + continue + flux = float(flux_by_label[label]) + peak = float(peak_by_label[label]) + # 单像素候选需要更高峰值,降低热像素/随机噪声排序权重,但不直接删除。 + # Single-pixel candidates need a stronger peak, reducing noise/hot-pixel priority without an opening. + min_peak_sigma = 4.5 if area <= 1 else 3.0 + if flux <= 0.0 or peak < max(2.0, min_peak_sigma * noise): + rejected_weak += 1 + continue + cx = float(centroids[label, 0]) + cy = float(centroids[label, 1]) + candidates.append( + FocusCandidate(x=cx, y=cy, flux=flux, area=area, source="auto") + ) + + candidates.sort(key=lambda item: item.flux, reverse=True) + return candidates[: self._max_stars], { + "pipeline": "focus_local_sigma_v1", + "noise_sigma": round(noise, 4), + "threshold": round(threshold, 4), + "components": max(0, count - 1), + "rejected_large": rejected_large, + "rejected_weak": rejected_weak, + } + + +@dataclass(slots=True) +class FocusStarMetric: + """单颗星点的焦点指标 / Focus metrics for one detected star.""" + + x: float + y: float + hfd_px: float + fwhm_px: float + snr: float + peak_snr: float + roundness: float + concentration: float + aperture_radius_px: float + peak: float + flux: float + saturated: bool + undersampled: bool + source: str + + def to_dict(self) -> dict[str, Any]: + """返回 JSON 友好的数值 / Return JSON-friendly values.""" + payload = asdict(self) + for key, value in payload.items(): + if isinstance(value, float): + payload[key] = round(value, 4) + return payload + + +class FocusMetricAnalyzer: + """在原始相机帧上测量 HFD 与辅助质量指标 / Measure HFD on raw frames.""" + + def __init__( + self, + *, + max_stars: int = 40, + stamp_radius: int = 24, + min_snr: float = 4.0, + min_peak_snr: float = 5.0, + min_roundness: float = 0.2, + ) -> None: + self._extractor = FocusCandidateExtractor(max_stars=max_stars) + self._stamp_radius = max(8, int(stamp_radius)) + self._min_snr = float(min_snr) + self._min_peak_snr = float(min_peak_snr) + self._min_roundness = float(min_roundness) + + @staticmethod + def _gray(frame: np.ndarray) -> np.ndarray: + """兼容灰度、RGB 与 RGBA 帧 / Accept grayscale, RGB, and RGBA frames.""" + data = np.asarray(frame) + if data.ndim == 2: + return data.astype(np.float32, copy=False) + if data.ndim == 3 and data.shape[2] >= 3: + # 相机主流是 RGB888;显式使用 RGB 亮度,避免压低偏红星点。 + # The main stream is RGB888; explicit RGB luminance avoids dimming red stars. + rgb = data[..., :3].astype(np.float32, copy=False) + return rgb[..., 0] * 0.299 + rgb[..., 1] * 0.587 + rgb[..., 2] * 0.114 + raise ValueError("unsupported focus frame shape / 不支持的焦点分析帧形状") + + @staticmethod + def _target_candidate( + gray: np.ndarray, *, target_x: float, target_y: float + ) -> FocusCandidate | None: + """在点击位置附近直接找局部峰值 / Find a local peak near the clicked target.""" + h, w = gray.shape + px = min(1.0, max(0.0, float(target_x))) * max(0, w - 1) + py = min(1.0, max(0.0, float(target_y))) * max(0, h - 1) + radius = max(8, min(24, int(round(min(h, w) * 0.025)))) + x0 = max(0, int(round(px)) - radius) + x1 = min(w, int(round(px)) + radius + 1) + y0 = max(0, int(round(py)) - radius) + y1 = min(h, int(round(py)) + radius + 1) + roi = np.asarray(gray[y0:y1, x0:x1], dtype=np.float32) + if roi.size == 0: + return None + background = float(np.median(roi)) + mad = float(np.median(np.abs(roi - background))) + noise = max(0.5, 1.4826 * mad) + index = int(np.argmax(roi)) + iy, ix = np.unravel_index(index, roi.shape) + peak_signal = float(roi[iy, ix] - background) + if peak_signal < max(2.0, 3.5 * noise): + return None + return FocusCandidate( + x=float(x0 + ix), + y=float(y0 + iy), + flux=peak_signal, + area=1, + source="target", + ) + + def _measure_star( + self, + gray: np.ndarray, + *, + x: float, + y: float, + saturation_level: float, + source: str, + ) -> tuple[FocusStarMetric | None, str | None]: + radius = self._stamp_radius + cx = int(round(x)) + cy = int(round(y)) + h, w = gray.shape + if cx - radius < 0 or cy - radius < 0: + return None, "edge" + if cx + radius >= w or cy + radius >= h: + return None, "edge" + + stamp = gray[cy - radius : cy + radius + 1, cx - radius : cx + radius + 1] + yy, xx = np.indices(stamp.shape, dtype=np.float32) + local_center = float(radius) + initial_r = np.hypot(xx - local_center, yy - local_center) + background_pixels = stamp[initial_r >= radius * 0.78] + if background_pixels.size == 0: + return None, "background" + background = float(np.median(background_pixels)) + mad = float(np.median(np.abs(background_pixels - background))) + noise = max(0.5, 1.4826 * mad) + residual = stamp - background + + # 候选矩心可能偏离亚像素峰值;在小邻域内重新居中。 + # Recenter on the local peak because a tiny connected-component centroid may be fractional. + search_radius = min(5, radius // 2) + centre_slice = residual[ + radius - search_radius : radius + search_radius + 1, + radius - search_radius : radius + search_radius + 1, + ] + peak_index = int(np.argmax(centre_slice)) + peak_y, peak_x = np.unravel_index(peak_index, centre_slice.shape) + peak_x = int(peak_x + radius - search_radius) + peak_y = int(peak_y + radius - search_radius) + radial_from_peak = np.hypot(xx - float(peak_x), yy - float(peak_y)) + peak_signal = max(0.0, float(residual[peak_y, peak_x])) + if peak_signal < max(3.5 * noise, 2.0): + return None, "low_peak" + + # 用显著连通域估计星点尺寸,再选择 3..10 px 自适应孔径。 + # Estimate source extent from the significant component, then use a 3..10 px aperture. + core_mask = np.asarray( + (residual >= max(noise, 0.75)) & (radial_from_peak <= 10.0), + dtype=np.uint8, + ) + component_count, component_labels = cv2.connectedComponents( + core_mask, connectivity=8 + ) + peak_label = int(component_labels[peak_y, peak_x]) + if component_count <= 1 or peak_label <= 0: + component_radius = 0.0 + else: + component_radius = float( + np.max(radial_from_peak[component_labels == peak_label]) + ) + aperture_radius = float(np.clip(component_radius + 2.0, 3.0, 10.0)) + aperture_mask = radial_from_peak <= aperture_radius + signal = np.clip(residual - 0.5 * noise, 0.0, None) + signal[~aperture_mask] = 0.0 + signal[residual < 0.75 * noise] = 0.0 + total_flux = float(np.sum(signal)) + peak = float(np.max(stamp)) + if total_flux <= 0.0: + return None, "empty_flux" + + centroid_x = float(np.sum(signal * xx) / total_flux) + centroid_y = float(np.sum(signal * yy) / total_flux) + dx = xx - centroid_x + dy = yy - centroid_y + radial = np.hypot(dx, dy) + order = np.argsort(radial, axis=None) + sorted_radius = radial.ravel()[order] + cumulative = np.cumsum(signal.ravel()[order]) + half_index = int(np.searchsorted(cumulative, total_flux * 0.5, side="left")) + half_index = min(half_index, sorted_radius.size - 1) + hfd = 2.0 * float(sorted_radius[half_index]) + + var_x = float(np.sum(signal * dx * dx) / total_flux) + var_y = float(np.sum(signal * dy * dy) / total_flux) + sigma_x = float(np.sqrt(max(0.0, var_x))) + sigma_y = float(np.sqrt(max(0.0, var_y))) + sigma_major = max(sigma_x, sigma_y) + sigma_minor = min(sigma_x, sigma_y) + roundness = sigma_minor / sigma_major if sigma_major > 1e-6 else 1.0 + fwhm = 2.35482 * float(np.sqrt(max(0.0, (var_x + var_y) / 2.0))) + + core_flux = float(np.sum(signal[radial <= 1.5])) + reference_flux = float(np.sum(signal[radial <= 4.5])) + concentration = core_flux / reference_flux if reference_flux > 0.0 else 0.0 + aperture_pixels = max(1, int(np.count_nonzero(aperture_mask))) + snr = total_flux / float( + np.sqrt(max(total_flux, 0.0) + aperture_pixels * noise**2) + ) + peak_snr = peak_signal / noise + saturated = peak >= saturation_level * 0.98 + undersampled = hfd < 1.0 or fwhm < 1.0 + + return ( + FocusStarMetric( + x=float(cx - radius + centroid_x), + y=float(cy - radius + centroid_y), + hfd_px=hfd, + fwhm_px=fwhm, + snr=snr, + peak_snr=peak_snr, + roundness=roundness, + concentration=concentration, + aperture_radius_px=aperture_radius, + peak=peak, + flux=total_flux, + saturated=saturated, + undersampled=undersampled, + source=source, + ), + None, + ) + + def analyze( + self, + frame: np.ndarray, + *, + frame_id: int, + timestamp: float, + target_x: float | None = None, + target_y: float | None = None, + ) -> dict[str, Any]: + """分析一帧并返回多星稳健统计 / Analyze one frame with robust multi-star statistics.""" + raw = np.asarray(frame) + gray = self._gray(raw) + h, w = gray.shape + if np.issubdtype(raw.dtype, np.integer): + saturation_level = float(np.iinfo(raw.dtype).max) + else: + # 浮点帧通常仍使用 0..1 或 0..255 标度;不能把当前帧峰值当作饱和上限。 + # Float frames commonly use 0..1 or 0..255; the current peak is not a saturation ceiling. + frame_peak = float(np.max(gray)) + saturation_level = 1.0 if frame_peak <= 1.5 else 255.0 + + candidates, detection = self._extractor.extract(gray) + target_candidate: FocusCandidate | None = None + if target_x is not None and target_y is not None: + target_candidate = self._target_candidate( + gray, target_x=target_x, target_y=target_y + ) + if target_candidate is not None: + candidates = [ + candidate + for candidate in candidates + if (candidate.x - target_candidate.x) ** 2 + + (candidate.y - target_candidate.y) ** 2 + >= 16.0 + ] + candidates.insert(0, target_candidate) + detection["target_forced"] = target_candidate is not None + + measured: list[FocusStarMetric] = [] + measurement_rejections: dict[str, int] = {} + for candidate in candidates: + metric, rejection = self._measure_star( + gray, + x=float(candidate.x), + y=float(candidate.y), + saturation_level=saturation_level, + source=candidate.source, + ) + if metric is not None: + measured.append(metric) + elif rejection is not None: + measurement_rejections[rejection] = ( + measurement_rejections.get(rejection, 0) + 1 + ) + + usable: list[FocusStarMetric] = [] + quality_rejections: dict[str, int] = {} + for star in measured: + reason: str | None = None + if star.saturated: + reason = "saturated" + elif ( + star.source == "auto" + and star.fwhm_px < 0.35 + and star.concentration >= 0.995 + ): + reason = "hot_pixel_like" + elif star.peak_snr < self._min_peak_snr: + reason = "low_peak_snr" + elif star.snr < self._min_snr: + reason = "low_snr" + elif star.roundness < self._min_roundness: + reason = "elongated" + elif star.hfd_px > min(18.0, self._stamp_radius * 1.2): + reason = "extended" + if reason is None: + usable.append(star) + else: + quality_rejections[reason] = quality_rejections.get(reason, 0) + 1 + usable.sort(key=lambda star: star.snr, reverse=True) + usable = usable[:20] + detection["measurement_rejections"] = measurement_rejections + detection["quality_rejections"] = quality_rejections + + warnings: list[str] = [] + if not candidates: + warnings.append("no_stars_detected") + if any(star.saturated for star in measured): + warnings.append("saturated_stars_rejected") + if any(star.undersampled for star in usable): + warnings.append("undersampled_stars") + if any( + star.source == "target" + and star.fwhm_px < 0.35 + and star.concentration >= 0.995 + for star in usable + ): + warnings.append("target_may_be_hot_pixel") + if candidates and not usable: + warnings.append("no_usable_stars") + elif len(usable) < 3: + warnings.append("low_star_count") + + selected: FocusStarMetric | None = None + if target_x is not None and target_y is not None: + selected = next((star for star in usable if star.source == "target"), None) + if selected is None: + warnings.append("target_star_not_found") + elif usable: + selected = usable[0] + + state = "measuring" + if not usable: + state = "no_stars" + elif len(usable) < 3: + state = "low_confidence" + + aggregate: dict[str, float] | None = None + if usable: + hfd_values = np.asarray([star.hfd_px for star in usable], dtype=np.float64) + fwhm_values = np.asarray( + [star.fwhm_px for star in usable], dtype=np.float64 + ) + concentration_values = np.asarray( + [star.concentration for star in usable], dtype=np.float64 + ) + median_hfd = float(np.median(hfd_values)) + aggregate = { + "median_hfd_px": round(median_hfd, 4), + "hfd_mad_px": round( + float(np.median(np.abs(hfd_values - median_hfd))), 4 + ), + "median_fwhm_px": round(float(np.median(fwhm_values)), 4), + "median_concentration": round( + float(np.median(concentration_values)), 4 + ), + } + + return { + "success": True, + "state": state, + "frame_id": int(frame_id), + "timestamp": float(timestamp), + "frame": {"width": int(w), "height": int(h)}, + "stars_detected": len(candidates), + "stars_measured": len(measured), + "stars_used": len(usable), + "detection": detection, + "aggregate": aggregate, + "selected_star": selected.to_dict() if selected is not None else None, + "stars": [star.to_dict() for star in usable], + "warnings": warnings, + } + + +focus_metric_analyzer = FocusMetricAnalyzer() + + +__all__ = ["FocusMetricAnalyzer", "FocusStarMetric", "focus_metric_analyzer"] diff --git a/ogscope/domain/camera/services.py b/ogscope/domain/camera/services.py index 5331904..984709e 100644 --- a/ogscope/domain/camera/services.py +++ b/ogscope/domain/camera/services.py @@ -4,6 +4,7 @@ from __future__ import annotations +import asyncio import logging import time from typing import Any @@ -12,6 +13,7 @@ from starlette.requests import Request from ogscope.config import get_settings +from ogscope.domain.camera.focus import focus_metric_analyzer from ogscope.domain.camera.stream_limiter import get_mjpeg_stream_limiter from ogscope.platform.adapters.debug_services import get_debug_services_module @@ -85,6 +87,25 @@ async def get_rate_limited_preview( _PREVIEW_CLIENT_LAST_TS[client_host] = now return await self.get_preview(since_frame_id=since_frame_id) + async def get_focus_metrics( + self, + *, + target_x: float | None = None, + target_y: float | None = None, + ) -> dict[str, Any]: + """读取原始帧并测量星点焦点质量 / Measure star focus quality on a raw frame.""" + from ogscope.web.camera_shared import get_camera_manager + + frame, frame_id, timestamp = await get_camera_manager().get_raw_frame() + return await asyncio.to_thread( + focus_metric_analyzer.analyze, + frame, + frame_id=frame_id, + timestamp=timestamp, + target_x=target_x, + target_y=target_y, + ) + async def get_product_camera_status( self, *, @@ -142,11 +163,22 @@ async def get_stream_status(self) -> dict[str, Any]: from ogscope.web.camera_shared import get_camera_manager metrics = await get_camera_manager().stream_metrics() + limiter_metrics = await limiter.snapshot() + configured_stall_timeout_ms = int(settings.stream_mjpeg_client_stall_timeout_ms) + effective_stall_timeout_ms = ( + max( + configured_stall_timeout_ms, + int(settings.stream_mjpeg_frame_fetch_timeout_ms) + 5000, + ) + if configured_stall_timeout_ms > 0 + else 0 + ) 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), + "client_stall_timeout_ms": effective_stall_timeout_ms, "target_preview_fps": int(metrics["preview_target_fps"]), + **limiter_metrics, **metrics, } @@ -240,6 +272,14 @@ async def update_settings(settings: dict[str, Any]): settings ) + @staticmethod + async def start_focus_session(): + return await _debug_services_module().DebugCameraService.start_focus_session() + + @staticmethod + async def stop_focus_session(): + return await _debug_services_module().DebugCameraService.stop_focus_session() + @staticmethod async def set_auto_exposure_mode(enabled: bool): return await _debug_services_module().DebugCameraService.set_auto_exposure_mode( diff --git a/ogscope/domain/camera/stream_limiter.py b/ogscope/domain/camera/stream_limiter.py index a952f48..d3e33f0 100644 --- a/ogscope/domain/camera/stream_limiter.py +++ b/ogscope/domain/camera/stream_limiter.py @@ -1,20 +1,53 @@ """ -MJPEG 长连接并发限制 / Concurrent MJPEG stream limiter +MJPEG 长连接会话限制 / Concurrent MJPEG stream session limiter. """ from __future__ import annotations import asyncio +import time +from collections import Counter +from dataclasses import dataclass from ogscope.config import get_settings +@dataclass +class _MjpegSessionState: + """保存单个流会话的活跃时间 / Track timing for one stream session.""" + + acquired_mono: float + last_progress_mono: float + + +class MjpegStreamLease: + """可幂等释放的 MJPEG 名额租约 / Idempotently releasable MJPEG slot lease.""" + + def __init__(self, limiter: MjpegStreamLimiter, session_id: int) -> None: + self._limiter = limiter + self._session_id = session_id + + async def touch(self) -> bool: + """记录一次已完成的下游发送 / Record one completed downstream send.""" + return await self._limiter._touch(self._session_id) + + async def idle_seconds(self) -> float | None: + """返回距最近发送进展的秒数 / Return seconds since the latest send progress.""" + return await self._limiter._idle_seconds(self._session_id) + + async def release(self, reason: str = "released") -> bool: + """幂等释放租约并记录原因 / Idempotently release the lease and record its reason.""" + return await self._limiter._release(self._session_id, reason) + + class MjpegStreamLimiter: - """限制同时活跃的 MJPEG 响应数,减轻低配板内存与 WiFi 压力 / Cap concurrent MJPEG responses.""" + """限制并跟踪 MJPEG 响应,防止失联客户端永久占位 / Limit and track MJPEG responses.""" def __init__(self, max_clients: int) -> None: self._max = max(0, int(max_clients)) - self._count = 0 + self._next_session_id = 1 + self._sessions: dict[int, _MjpegSessionState] = {} + self._release_reasons: Counter[str] = Counter() self._lock = asyncio.Lock() @property @@ -23,24 +56,65 @@ def max_clients(self) -> int: @property def active_clients(self) -> int: - return self._count + return len(self._sessions) - async def try_acquire(self) -> bool: - """若未超限则占用一个名额并返回 True / Acquire one slot if under limit.""" - if self._max <= 0: - return True + async def try_acquire(self) -> MjpegStreamLease | None: + """若未超限则返回会话租约 / Return a session lease when under the limit.""" + async with self._lock: + if self._max > 0 and len(self._sessions) >= self._max: + return None + session_id = self._next_session_id + self._next_session_id += 1 + now = time.monotonic() + self._sessions[session_id] = _MjpegSessionState(now, now) + return MjpegStreamLease(self, session_id) + + async def snapshot(self) -> dict[str, object]: + """生成无敏感标识的会话指标 / Build session metrics without client identifiers.""" + async with self._lock: + now = time.monotonic() + states = list(self._sessions.values()) + return { + "active_clients": len(states), + "oldest_client_age_ms": int( + max((now - state.acquired_mono for state in states), default=0.0) + * 1000 + ), + "oldest_client_idle_ms": int( + max( + (now - state.last_progress_mono for state in states), + default=0.0, + ) + * 1000 + ), + "released_clients_total": sum(self._release_reasons.values()), + "stalled_clients_total": self._release_reasons.get( + "client_stall_timeout", 0 + ), + "release_reasons": dict(self._release_reasons), + } + + async def _touch(self, session_id: int) -> bool: async with self._lock: - if self._count >= self._max: + state = self._sessions.get(session_id) + if state is None: return False - self._count += 1 + state.last_progress_mono = time.monotonic() return True - async def release(self) -> None: - """释放一个名额(与 try_acquire 成对)/ Release one slot.""" - if self._max <= 0: - return + async def _idle_seconds(self, session_id: int) -> float | None: async with self._lock: - self._count = max(0, self._count - 1) + state = self._sessions.get(session_id) + if state is None: + return None + return max(0.0, time.monotonic() - state.last_progress_mono) + + async def _release(self, session_id: int, reason: str) -> bool: + async with self._lock: + if self._sessions.pop(session_id, None) is None: + return False + self._release_reasons[reason or "released"] += 1 + return True _limiter: MjpegStreamLimiter | None = None diff --git a/ogscope/domain/camera/streaming.py b/ogscope/domain/camera/streaming.py index dfe5df4..f928d8f 100644 --- a/ogscope/domain/camera/streaming.py +++ b/ogscope/domain/camera/streaming.py @@ -7,17 +7,138 @@ import asyncio import logging import time +from collections.abc import Coroutine +from typing import Any from fastapi import HTTPException from fastapi.responses import StreamingResponse +from starlette.background import BackgroundTask from starlette.requests import Request 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.domain.camera.stream_limiter import ( + MjpegStreamLease, + get_mjpeg_stream_limiter, +) from ogscope.web.camera_shared import get_camera_manager from ogscope.web.mjpeg_stream_helpers import mjpeg_sleep_or_disconnect +_cleanup_tasks: set[asyncio.Task[Any]] = set() +_module_logger = logging.getLogger(__name__) + + +def _finish_cleanup_task(task: asyncio.Task[Any]) -> None: + """移除并读取清理结果,避免遗失后台异常 / Remove the task and consume background errors.""" + _cleanup_tasks.discard(task) + if task.cancelled(): + return + exc = task.exception() + if exc is not None: + _module_logger.error( + "mjpeg_cleanup_failed error=%s", + exc, + exc_info=(type(exc), exc, exc.__traceback__), + ) + + +def _retain_cleanup_task(coro: Coroutine[Any, Any, Any]) -> asyncio.Task[Any]: + """保留清理任务直至完成,避免调用方取消中断资源回收 / Retain cleanup across caller cancellation.""" + task = asyncio.create_task(coro) + _cleanup_tasks.add(task) + task.add_done_callback(_finish_cleanup_task) + return task + + +class _MjpegStreamSession: + """统一持有流租约与相机消费者 / Own the stream lease and camera consumer together.""" + + def __init__( + self, + *, + lease: MjpegStreamLease, + manager: Any, + stall_timeout_s: float, + logger: logging.Logger, + path: str, + ) -> None: + self._lease = lease + self._manager = manager + self._stall_timeout_s = max(0.0, stall_timeout_s) + self._logger = logger + self._path = path + self._preview_acquired = False + self._closed = False + self._cleanup_task: asyncio.Task[Any] | None = None + self._watchdog_task: asyncio.Task[Any] | None = None + + @property + def closed(self) -> bool: + return self._closed + + def start_watchdog(self) -> None: + """在响应开始前启动发送停滞看门狗 / Start the send-stall watchdog before response iteration.""" + if self._stall_timeout_s > 0 and self._watchdog_task is None: + self._watchdog_task = asyncio.create_task(self._watch_for_stall()) + + async def acquire_preview_consumer(self) -> bool: + """获取相机消费者,并处理获取期间发生的超时 / Acquire camera consumer safely across timeout.""" + await self._manager.acquire_preview_consumer() + if self._closed: + task = _retain_cleanup_task(self._manager.release_preview_consumer()) + await asyncio.shield(task) + return False + self._preview_acquired = True + return True + + async def touch(self) -> None: + """仅在上一帧已完成 ASGI 发送后刷新进展 / Refresh progress only after the prior ASGI send completed.""" + await self._lease.touch() + + async def close(self, reason: str = "response_complete") -> None: + """幂等关闭;独立清理任务可跨越调用方取消 / Close idempotently in a cancellation-safe task.""" + if self._cleanup_task is None: + self._closed = True + self._cleanup_task = _retain_cleanup_task(self._close_once(reason)) + await asyncio.shield(self._cleanup_task) + + async def _close_once(self, reason: str) -> None: + watchdog = self._watchdog_task + current = asyncio.current_task() + if watchdog is not None and watchdog is not current: + watchdog.cancel() + + # 先释放并发租约,再等待相机停止 / Release the slot before awaiting camera shutdown. + released = await self._lease.release(reason) + if released: + self._logger.info( + "mjpeg_session_released reason=%s path=%s", reason, self._path + ) + + if self._preview_acquired: + self._preview_acquired = False + await self._manager.release_preview_consumer() + + async def _watch_for_stall(self) -> None: + interval = min(1.0, max(0.05, self._stall_timeout_s / 4.0)) + try: + while not self._closed: + await asyncio.sleep(interval) + idle_s = await self._lease.idle_seconds() + if idle_s is None: + return + if idle_s >= self._stall_timeout_s: + self._logger.warning( + "mjpeg_client_stall_timeout idle_ms=%s timeout_ms=%s path=%s", + int(idle_s * 1000), + int(self._stall_timeout_s * 1000), + self._path, + ) + await self.close("client_stall_timeout") + return + except asyncio.CancelledError: + return + async def build_camera_mjpeg_stream( request: Request, @@ -30,28 +151,52 @@ async def build_camera_mjpeg_stream( ) -> StreamingResponse: """构建 MJPEG 流响应 / Build MJPEG stream response.""" limiter = get_mjpeg_stream_limiter() - if not await limiter.try_acquire(): - _path = str(getattr(getattr(request, "url", None), "path", "") or "") + lease = await limiter.try_acquire() + if lease is None: + path = str(getattr(getattr(request, "url", None), "path", "") or "") logger.warning( "mjpeg_try_acquire_rejected active=%s max=%s path=%s", limiter.active_clients, limiter.max_clients, - _path, + path, ) raise HTTPException(status_code=503, detail=limit_detail) + boundary = "frame" settings = get_settings() fetch_timeout_s = settings.stream_mjpeg_frame_fetch_timeout_ms / 1000.0 + configured_stall_timeout_ms = settings.stream_mjpeg_client_stall_timeout_ms + stall_timeout_s = ( + max( + configured_stall_timeout_ms, + settings.stream_mjpeg_frame_fetch_timeout_ms + 5000, + ) + / 1000.0 + if configured_stall_timeout_ms > 0 + else 0.0 + ) content_type = "image/jpeg" if image_format.lower() == "jpeg" else "image/png" + path = str(getattr(getattr(request, "url", None), "path", "") or "") + manager = get_camera_manager() + session = _MjpegStreamSession( + lease=lease, + manager=manager, + stall_timeout_s=stall_timeout_s, + logger=logger, + path=path, + ) + session.start_watchdog() async def frame_generator(): - manager = get_camera_manager() + release_reason = "response_complete" try: - await manager.acquire_preview_consumer() + if not await session.acquire_preview_consumer(): + return last_snap_frame_id = -1 last_emit_mono = 0.0 - while True: + while not session.closed: if await request.is_disconnected(): + release_reason = "client_disconnect" break try: code, data, snap_id = await asyncio.wait_for( @@ -61,20 +206,24 @@ async def frame_generator(): timeout=fetch_timeout_s, ) except asyncio.TimeoutError: + release_reason = "frame_fetch_timeout" logger.warning(timeout_log_message) break if code == 304: if not await mjpeg_sleep_or_disconnect(request, 0.03): + release_reason = "client_disconnect" break continue if code != 200 or data is None: if not await mjpeg_sleep_or_disconnect(request, 0.05): + release_reason = "client_disconnect" 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): + release_reason = "client_disconnect" break last_snap_frame_id = snap_id last_emit_mono = time.monotonic() @@ -91,11 +240,25 @@ async def frame_generator(): + data + b"\r\n" ) + await session.touch() + except asyncio.CancelledError: + release_reason = "cancelled" + raise + except GeneratorExit: + release_reason = "generator_closed" + raise + except Exception: + release_reason = "stream_error" + raise finally: - await manager.release_preview_consumer() - await limiter.release() + await session.close(release_reason) return StreamingResponse( frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}", + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", + "X-Accel-Buffering": "no", + }, + background=BackgroundTask(session.close, "response_complete"), ) diff --git a/ogscope/main.py b/ogscope/main.py index 8527e9d..d965c75 100644 --- a/ogscope/main.py +++ b/ogscope/main.py @@ -53,6 +53,9 @@ async def main() -> int: port=settings.port, reload=settings.reload, log_level=settings.log_level.lower(), + # Product middleware logs mutations and failures; omit high-rate polling access lines. + # 产品中间件记录写操作与失败;关闭高频轮询的逐请求访问日志。 + access_log=False, ) server = uvicorn.Server(config) await server.serve() diff --git a/ogscope/platform/hardware/camera.py b/ogscope/platform/hardware/camera.py index 076889d..ff03a70 100644 --- a/ogscope/platform/hardware/camera.py +++ b/ogscope/platform/hardware/camera.py @@ -5,11 +5,16 @@ """ import logging +import math +import time from abc import ABC, abstractmethod +from concurrent.futures import TimeoutError as FutureTimeoutError +from pathlib import Path from typing import Any, Optional import numpy as np +from ogscope.camera_optics import IMX327_16MM_F14_OPTICS from ogscope.domain.camera.driver import CameraCapabilities, LinuxpyV4L2Driver logger = logging.getLogger(__name__) @@ -58,8 +63,15 @@ class IMX327MIPICamera(CameraInterface): """IMX327 MIPI 相机驱动 - 基于 Picamera2 / IMX327 MIPI camera driver - based on Picamera2""" SENSOR_MAX_WIDTH = 1920 - SENSOR_MAX_HEIGHT = 1020 + SENSOR_MAX_HEIGHT = 1080 PREVIEW_BUFFER_COUNT = 2 + AE_SUPERVISOR_INTERVAL_S = 1.0 + AE_SUPERVISOR_STEP_EV = 0.5 + AE_DARK_CONFIRM_FRAMES = 2 + AE_BRIGHT_CONFIRM_FRAMES = 2 + AUTO_EXPOSURE_MAX_US = 1_000_000 + LUMINANCE_STATS_MAX_SAMPLES = 32_768 + PRODUCT_TUNING_FILE = Path(__file__).with_name("tuning") / "imx327.json" MANUAL_CONTROL_RANGE_DEFAULTS = { "ExposureTime": {"min": 1000, "max": 100000, "default": 10000, "step": 1000}, "AnalogueGain": {"min": 1.0, "max": 16.0, "default": 1.0, "step": 0.1}, @@ -76,8 +88,19 @@ def __init__(self, config: dict[str, Any]): self.backend_name = "picamera2/libcamera" self.output_pixel_format = "RGB888" self._frame_duration_limits: tuple[int, int] | None = None + self._frame_duration_control = "not_applied" + self._frame_duration_control_error: str | None = None self._lores_available = False self._last_lores_stats: dict[str, Any] = {} + self._pending_capture_job = None + self.tuning_file = ( + Path(str(config["tuning_file"])).expanduser() + if config.get("tuning_file") + else None + ) + self._tuning_source = "system_default" + self._tuning_loaded = False + self._tuning_error: str | None = None # 相机参数 / Camera parameters requested_width = int(config.get("width", 640)) @@ -100,7 +123,16 @@ def __init__(self, config: dict[str, Any]): 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.auto_exposure_max_us = max( + 10_000, + min( + self.AUTO_EXPOSURE_MAX_US, + int(config.get("auto_exposure_max_us", self.AUTO_EXPOSURE_MAX_US)), + ), + ) + self.capture_timeout_sec = max( + 0.5, float(config.get("capture_timeout_sec", 4.0)) + ) 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")) @@ -123,7 +155,22 @@ def __init__(self, config: dict[str, Any]): # 电子极轴镜默认 AE 策略(约 16mm 广角、低帧率夜空);仍由 libcamera ISP 闭环 / Polar-scope AE defaults (16mm, dark sky; ISP AE loop). self.ae_polar_preset = bool(config.get("ae_polar_preset", True)) - self.ae_exposure_value = float(config.get("ae_exposure_value", 0.35)) + self.ae_exposure_value = float(config.get("ae_exposure_value", 1.0)) + self.ae_aggressive_enabled = bool(config.get("ae_aggressive_enabled", True)) + # Start every AE session from neutral. Dark-scene supervision may then + # raise EV, while daylight never inherits a night-sky bias at startup. + # 每次 AE 会话都从中性补偿开始;暗场监督随后按需抬升 EV,白天启动时不会 + # 继承夜空增益偏置。 + self._ae_effective_exposure_value = 0.0 + self._ae_last_adjust_at = 0.0 + self._ae_scene_mode = "evaluating" + self._ae_dark_frames = 0 + self._ae_bright_frames = 0 + self._ae_control_backend = "unavailable" + self._ae_requested_exposure_mode = "long" + self._ae_requested_constraint_mode = "normal" + self._ae_applied_controls: list[str] = [] + self._ae_control_error: str | None = None logger.info( f"初始化 IMX327 MIPI 相机: {self.width}x{self.height}@{self.fps}fps" @@ -272,7 +319,7 @@ def _resolve_sampling_layout( if mode not in {"supersample", "native", "crop"}: mode = "native" if mode == "supersample": - # 先采满幅 1920×1020,再经 _resize_preserve_fov 缩到输出(保留整幅视场,非中心裁切) + # 先采满幅 1920×1080,再经 _resize_preserve_fov 缩到输出(保留整幅视场,非中心裁切) # Full sensor readout then letterbox resize to output (full FOV preserved, not center crop). capture_w = self.SENSOR_MAX_WIDTH capture_h = self.SENSOR_MAX_HEIGHT @@ -378,19 +425,26 @@ def _compute_frame_duration_limits(self) -> tuple[int, int]: def _apply_frame_duration_controls(self) -> None: """优先应用 FrameDurationLimits,失败时回退 FrameRate / Prefer FrameDurationLimits, fallback to FrameRate.""" if not self.camera: + self._frame_duration_control = "camera_unavailable" return limits = self._compute_frame_duration_limits() self._frame_duration_limits = limits + self._frame_duration_control_error = None if self._control_supported("FrameDurationLimits"): try: self.camera.set_controls({"FrameDurationLimits": limits}) + self._frame_duration_control = "frame_duration_limits" return except Exception as e: + self._frame_duration_control_error = type(e).__name__ logger.debug("FrameDurationLimits 未生效,回退 FrameRate: %s", e) try: self.camera.set_controls({"FrameRate": float(self.fps)}) - except Exception: - pass + self._frame_duration_control = "frame_rate_fallback" + except Exception as e: + self._frame_duration_control = "failed" + self._frame_duration_control_error = type(e).__name__ + logger.debug("帧周期控制未生效 / Frame-duration control failed: %s", e) def _noise_reduction_control_value(self) -> Any: """将语义模式映射到 libcamera 降噪枚举/整数 / Map semantic NR mode to libcamera value.""" @@ -482,30 +536,199 @@ def _create_video_configuration(self) -> Any: 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: + @staticmethod + def _histogram_percentile(histogram: np.ndarray, percentile: float) -> int: + """从 8 位直方图读取最近秩百分位 / Read nearest-rank percentile from an 8-bit histogram.""" + total = int(histogram.sum()) + if total <= 0: + return 0 + rank = max(1, int(math.ceil(total * percentile / 100.0))) + return int(np.searchsorted(np.cumsum(histogram), rank, side="left")) + + def _luminance_stats(self, luminance: np.ndarray, *, source: str) -> dict[str, Any]: + """用有界采样直方图计算亮度统计 / Compute luminance stats with a bounded histogram sample.""" + values = np.asarray(luminance) + if values.ndim >= 2 and values.size > self.LUMINANCE_STATS_MAX_SAMPLES: + stride = max( + 1, + int( + math.ceil(math.sqrt(values.size / self.LUMINANCE_STATS_MAX_SAMPLES)) + ), + ) + values = values[::stride, ::stride] + values = np.clip(values.reshape(-1), 0, 255).astype(np.uint8, copy=False) + histogram = np.bincount(values, minlength=256) + sample_count = int(histogram.sum()) + if sample_count <= 0: + return {} + levels = np.arange(256, dtype=np.float64) + return { + "source": source, + "sample_count": sample_count, + "mean": float(np.dot(histogram, levels) / sample_count), + "min": int(np.flatnonzero(histogram)[0]), + "max": int(np.flatnonzero(histogram)[-1]), + "p50": float(self._histogram_percentile(histogram, 50.0)), + "p90": float(self._histogram_percentile(histogram, 90.0)), + "p99": float(self._histogram_percentile(histogram, 99.0)), + "p99_8": float(self._histogram_percentile(histogram, 99.8)), + "saturated_fraction": float(histogram[250:].sum() / sample_count), + } + + def _collect_lores_stats( + self, request: Any, main_image: np.ndarray | None = None + ) -> None: + """优先从 lores 提取统计,失败时有界采样主流 / Prefer lores stats; bounded main-stream fallback.""" + luminance: np.ndarray | None = None + source = "main_fallback" + if self._lores_available: + try: + lores = request.make_array("lores") + if lores is not None: + if ( + len(getattr(lores, "shape", ())) == 2 + and lores.shape[0] >= self.lores_height + ): + luminance = lores[: self.lores_height, :] + elif len(getattr(lores, "shape", ())) >= 3: + luminance = lores[..., 0] + else: + luminance = lores + source = "lores" + except Exception as e: + logger.debug( + "读取 lores 统计失败,回退主流 / Lores stats failed; using main stream: %s", + e, + ) + if luminance is None and main_image is not None: + # RGB 主流用绿色通道作为低分配亮度近似;曝光监督不需要精确色度转换。 + # Use green as a low-allocation RGB luminance proxy; AE supervision does not need chroma accuracy. + luminance = main_image[..., 1] if main_image.ndim >= 3 else main_image + if luminance is None: return try: - lores = request.make_array("lores") - if lores is None: + stats = self._luminance_stats(luminance, source=source) + if not stats: 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)), - } + self._last_lores_stats = stats + self._update_aggressive_auto_exposure(stats) except Exception as e: - logger.debug("读取 lores 统计失败 / Failed to read lores stats: %s", e) + logger.debug("亮度统计失败 / Failed to compute luminance stats: %s", e) + + def _desired_aggressive_ae_ev(self, stats: dict[str, Any]) -> float: + """按暗部与高光占比选择 AE 补偿 / Select AE bias from shadows and highlight share.""" + p50 = float(stats.get("p50") or 0.0) + saturated = float(stats.get("saturated_fraction") or 0.0) + base = max(-2.0, min(2.0, float(self.ae_exposure_value))) + + # A tiny clipped lamp must not black out the rest of the scene. Allow up + # to 2% clipping and aggressively lift a dark median; broad daylight or + # widespread clipping falls back to neutral compensation. + # 少量过曝路灯不应压黑整幅画面;允许最多 2% 高光裁切并积极抬升暗部中位数, + # 白天或大面积过曝时则回落到中性补偿。 + if p50 >= 180.0 or saturated >= 0.20: + return 0.0 + if p50 <= 40.0 and saturated <= 0.02: + return max(base, 1.5) + if p50 <= 80.0 and saturated <= 0.05: + return max(base, 1.0) + if p50 <= 120.0 and saturated <= 0.08: + return max(0.5, min(base, 1.0)) + return min(base, 0.5) + + def _classify_ae_scene(self, stats: dict[str, Any]) -> str: + """用低成本亮度统计识别星空或明亮场景 / Classify starfield or bright scenes cheaply.""" + p50 = float(stats.get("p50") or 0.0) + p99_8 = float(stats.get("p99_8") or stats.get("p99") or 0.0) + saturated = float(stats.get("saturated_fraction") or 0.0) + dark = p50 <= 80.0 and p99_8 <= 200.0 and saturated <= 0.02 + bright = p50 >= 160.0 or saturated >= 0.08 + + self._ae_dark_frames = self._ae_dark_frames + 1 if dark else 0 + self._ae_bright_frames = self._ae_bright_frames + 1 if bright else 0 + if p50 >= 180.0 or saturated >= 0.20: + return "daylight" + if self._ae_bright_frames >= self.AE_BRIGHT_CONFIRM_FRAMES: + return "daylight" + if self._ae_dark_frames >= self.AE_DARK_CONFIRM_FRAMES: + return "starfield" + return self._ae_scene_mode + + @staticmethod + def _load_ae_control_namespace() -> tuple[Any | None, str]: + """优先读取 libcamera 枚举,兼容旧 Picamera2 导出 / Prefer libcamera AE enums.""" + try: + from libcamera import controls as control_namespace + + return control_namespace, "libcamera.controls" + except ImportError: + try: + from picamera2 import controls as control_namespace + + return control_namespace, "picamera2.controls" + except ImportError: + return None, "unavailable" + + def _reset_autonomous_ae_state(self) -> None: + """新 AE 会话从无场景偏见状态开始 / Start a fresh AE session without stale scene bias.""" + self._ae_effective_exposure_value = 0.0 + self._ae_last_adjust_at = 0.0 + self._ae_scene_mode = "evaluating" + self._ae_dark_frames = 0 + self._ae_bright_frames = 0 + self._ae_requested_exposure_mode = "long" + self._ae_requested_constraint_mode = "normal" + self._ae_applied_controls = [] + self._ae_control_error = None + + def _update_aggressive_auto_exposure(self, stats: dict[str, Any]) -> None: + """自主切换星空 AE 并慢速调整 EV / Autonomously select starfield AE and supervise EV.""" + if ( + not self.camera + or not self.auto_exposure + or not self.ae_polar_preset + or not self.ae_aggressive_enabled + ): + return + now = time.monotonic() + if now - self._ae_last_adjust_at < self.AE_SUPERVISOR_INTERVAL_S: + return + scene_mode = self._classify_ae_scene(stats) + if scene_mode != self._ae_scene_mode: + self._ae_scene_mode = scene_mode + self._apply_polar_auto_exposure_controls() + desired = self._desired_aggressive_ae_ev(stats) + current = float(self._ae_effective_exposure_value) + delta = desired - current + if abs(delta) < 0.1: + self._ae_last_adjust_at = now + return + if desired <= 0.0 and ( + float(stats.get("p50") or 0.0) >= 180.0 + or float(stats.get("saturated_fraction") or 0.0) >= 0.20 + ): + # Broad clipping needs an immediate daylight escape; a slow EV ramp + # leaves the preview unusable for several frames. + # 大面积过曝时立即回到日间中性值;逐档回落会让预览连续多帧不可用。 + next_ev = 0.0 + else: + step = min(abs(delta), self.AE_SUPERVISOR_STEP_EV) + next_ev = current + (step if delta > 0 else -step) + controls = self._camera_controls() + try: + if "ExposureValue" in controls: + self.camera.set_controls({"ExposureValue": next_ev}) + elif "Brightness" in controls: + self.camera.set_controls( + {"Brightness": max(-1.0, min(1.0, next_ev * 0.2))} + ) + else: + return + except Exception as exc: + logger.debug("动态 AE 补偿未生效 / Dynamic AE bias failed: %s", exc) + return + self._ae_effective_exposure_value = next_ev + self._ae_last_adjust_at = now def _camera_capabilities(self) -> dict[str, Any]: """汇总相机能力供 API/UI 降级 / Summarize camera capabilities for API/UI fallback.""" @@ -538,7 +761,7 @@ def _camera_capabilities(self) -> dict[str, Any]: } def _apply_polar_auto_exposure_controls(self) -> None: - """libcamera AE 预设:暗部优先、矩阵测光、偏长曝光、EV;失败项跳过 / AE preset; skip unsupported controls.""" + """应用自主场景 AE:星空优先长曝光,明亮场景快速恢复 / Apply autonomous scene AE.""" if not self.camera or not self.auto_exposure: return if not self.ae_polar_preset: @@ -549,43 +772,71 @@ def _apply_polar_auto_exposure_controls(self) -> None: except Exception as e: logger.debug("AeEnable only: %s", e) return - try: - from picamera2 import controls as pcc - except ImportError: - return - cc = getattr(self.camera, "camera_controls", None) or {} updates: dict[str, Any] = {"AeEnable": True} - if hasattr(pcc, "AeConstraintModeEnum"): - updates["AeConstraintMode"] = pcc.AeConstraintModeEnum.Shadows - if hasattr(pcc, "AeMeteringModeEnum"): - updates["AeMeteringMode"] = pcc.AeMeteringModeEnum.Matrix - if hasattr(pcc, "AeExposureModeEnum"): - updates["AeExposureMode"] = pcc.AeExposureModeEnum.Long - ev = float(self.ae_exposure_value) - if abs(ev) > 1e-6: - if "ExposureValue" in cc: - updates["ExposureValue"] = ev - elif "Brightness" in cc: - updates["Brightness"] = max(-1.0, min(1.0, ev * 0.2)) + pcc, backend = self._load_ae_control_namespace() + self._ae_control_backend = backend + starfield = self._ae_scene_mode == "starfield" + self._ae_requested_constraint_mode = "shadows" if starfield else "normal" + # Long is a shutter-priority curve, not a forced one-second exposure. + # It still shortens exposure in daylight while avoiding noisy gain-first + # behavior in the product's primary starfield use case. + # Long 是快门优先曲线而非强制一秒曝光;白天仍会主动缩短曝光,同时避免 + # 产品核心星空场景退回高增益优先行为。 + self._ae_requested_exposure_mode = "long" + if pcc is not None: + constraint_enum = getattr(pcc, "AeConstraintModeEnum", None) + metering_enum = getattr(pcc, "AeMeteringModeEnum", None) + exposure_enum = getattr(pcc, "AeExposureModeEnum", None) + if constraint_enum is not None and "AeConstraintMode" in cc: + updates["AeConstraintMode"] = getattr( + constraint_enum, + "Shadows" if starfield else "Normal", + ) + if metering_enum is not None and "AeMeteringMode" in cc: + updates["AeMeteringMode"] = metering_enum.Matrix + if exposure_enum is not None and "AeExposureMode" in cc: + updates["AeExposureMode"] = exposure_enum.Long + ev = float(self._ae_effective_exposure_value) + # Explicitly write the neutral value too. Omitting zero can leave a + # previous dark-scene bias active after returning to daylight. + # 中性值也必须显式写入;省略零值会让此前的暗场补偿残留到白天。 + if "ExposureValue" in cc: + updates["ExposureValue"] = ev + elif "Brightness" in cc: + 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) + self._ae_applied_controls = sorted(updates) + self._ae_control_error = None logger.info( - "已应用电子极轴镜 AE 预设 (Shadows/Matrix/Long, EV≈%.2f)", + "已应用自主 AE 场景=%s constraint=%s exposure=%s EV≈%.2f / " + "Applied autonomous AE scene=%s constraint=%s exposure=%s EV≈%.2f", + self._ae_scene_mode, + self._ae_requested_constraint_mode, + self._ae_requested_exposure_mode, + ev, + self._ae_scene_mode, + self._ae_requested_constraint_mode, + self._ae_requested_exposure_mode, ev, ) except Exception as e: + self._ae_control_error = type(e).__name__ logger.warning("AE 预设批量设置失败,逐项重试: %s", e) + applied: list[str] = [] for key, val in updates.items(): if key != "AeEnable" and key not in cc: continue try: self.camera.set_controls({key: val}) + applied.append(key) except Exception as err: logger.debug("AE 控制 %s 未生效: %s", key, err) + self._ae_applied_controls = sorted(applied) def _white_balance_controls(self) -> dict[str, Any]: """生成白平衡控制;auto 必须真正打开 AWB / Build WB controls; auto must really enable AWB.""" @@ -632,12 +883,56 @@ def _apply_white_balance_controls(self) -> None: return self.camera.set_controls(self._white_balance_controls()) + def _load_picamera_tuning(self, picamera_type: Any) -> dict[str, Any] | None: + """加载产品或显式覆盖 tuning,失败时允许系统默认回退 / Load product or override tuning with safe fallback.""" + tuning_path = self.tuning_file or self.PRODUCT_TUNING_FILE + source_prefix = "override" if self.tuning_file else "product" + self._tuning_source = f"{source_prefix}:{tuning_path.name}" + loader = getattr(picamera_type, "load_tuning_file", None) + if not callable(loader): + self._tuning_source = "system_default" + self._tuning_error = "load_tuning_file_unavailable" + return None + try: + tuning = loader(tuning_path.name, dir=str(tuning_path.parent)) + self._tuning_loaded = True + self._tuning_error = None + return tuning + except Exception as e: + # API 只报告错误类型,完整路径只进入本机日志,避免泄露部署目录。 + # The API reports only the error type; the full local path stays in logs. + self._tuning_source = "system_default" + self._tuning_loaded = False + self._tuning_error = type(e).__name__ + logger.warning( + "相机 tuning 加载失败,回退系统默认 / Camera tuning load failed; using system default (%s): %s", + tuning_path, + e, + ) + return None + def initialize(self) -> bool: """初始化 MIPI 相机 / Initialize MIPI camera""" try: from picamera2 import Picamera2 - self.camera = Picamera2() + tuning = self._load_picamera_tuning(Picamera2) + if tuning is None: + self.camera = Picamera2() + else: + try: + self.camera = Picamera2(tuning=tuning) + except TypeError as e: + # 旧版 Picamera2 构造器可能不支持 tuning 参数,保持可启动并明确降级。 + # Older Picamera2 constructors may reject tuning; keep startup viable and report fallback. + self._tuning_source = "system_default" + self._tuning_loaded = False + self._tuning_error = type(e).__name__ + logger.warning( + "Picamera2 不接受产品 tuning,回退系统默认 / Picamera2 rejected product tuning; using default: %s", + e, + ) + self.camera = Picamera2() # 配置主流 + 可选 lores 流;RGB888 保证预览/解算色序一致 # Configure main + optional lores stream; RGB888 keeps preview/solve color order stable. @@ -647,17 +942,30 @@ def initialize(self) -> bool: # 设置相机控制参数 / Set camera control parameters # 构建控制参数,兼容部分固件未提供 DigitalGain 的情况 / Build control parameters, compatible with some firmwares that do not provide DigitalGain - controls = { - "ExposureTime": self.exposure_us, - "AnalogueGain": self.analogue_gain, - "AeEnable": self.auto_exposure, - } + controls = {"AeEnable": self.auto_exposure} + if not self.auto_exposure: + # Manual exposure/gain must not be queued together with AE. + # Some IMX327/libcamera combinations keep those seed values + # latched, which can leave daylight fixed at 10 ms and white. + # 自动曝光启动时不能同时排队手动曝光/增益;部分 IMX327/libcamera + # 组合会锁住这些初值,导致白天固定在 10 ms 并整幅过曝。 + controls.update( + { + "ExposureTime": self.exposure_us, + "AnalogueGain": self.analogue_gain, + } + ) controls.update(self._white_balance_controls()) - try: - self.camera.set_controls({**controls, "DigitalGain": self.digital_gain}) - except Exception: - # DigitalGain 不被支持时,退化为不设置该项 / When DigitalGain is not supported, it will degenerate to not setting this item. + if self.auto_exposure: self.camera.set_controls(controls) + else: + try: + self.camera.set_controls( + {**controls, "DigitalGain": self.digital_gain} + ) + except Exception: + # DigitalGain 不被支持时,退化为不设置该项 / When DigitalGain is unsupported, omit it. + self.camera.set_controls(controls) if self.auto_exposure: self._apply_polar_auto_exposure_controls() @@ -689,11 +997,11 @@ def start_capture(self) -> bool: # 设置帧周期(优先 FrameDurationLimits) / Set frame period (prefer FrameDurationLimits). self._apply_frame_duration_controls() - # 重新配置后重放曝光控制,避免状态漂移到驱动默认值 / Replay exposure control after reconfiguration to avoid state drift to driver defaults + # 手动控制可在启动前排队;AE 必须在流启动后再次启用,确保 IMX327/libcamera + # 真正进入闭环而不是停留在初始化曝光。 / Manual controls may be queued + # before start; re-enable AE after streaming so the driver enters its loop. try: - if self.auto_exposure: - self._apply_polar_auto_exposure_controls() - else: + if not self.auto_exposure: controls = { "AeEnable": False, "ExposureTime": self.exposure_us, @@ -713,6 +1021,9 @@ def start_capture(self) -> bool: self.camera.start() self.is_capturing = True + if self.auto_exposure: + self._reset_autonomous_ae_state() + self._apply_polar_auto_exposure_controls() logger.info("相机开始捕获") return True except Exception as e: @@ -727,6 +1038,7 @@ def stop_capture(self) -> bool: try: self.camera.stop() self.is_capturing = False + self._pending_capture_job = None logger.info("相机停止捕获") return True except Exception as e: @@ -744,13 +1056,26 @@ def capture_image(self) -> Optional[np.ndarray]: return None try: - # 同一请求读取图像与元数据,避免额外等待下一帧 - # Read image and metadata from one request to avoid waiting for another frame. - request = self.camera.capture_request() + # 使用 Picamera2 异步任务并限制等待时间,避免 libcamera 卡住后永久占用抓帧线程。 + # Use a bounded Picamera2 job so a stuck libcamera request cannot hold the grabber forever. + if self._pending_capture_job is None: + self._pending_capture_job = self.camera.capture_request(wait=False) + job = self._pending_capture_job + try: + request = self.camera.wait(job, timeout=self.capture_timeout_sec) + except FutureTimeoutError: + logger.error( + "相机抓帧超时(%.2fs),等待生命周期管理器回收 / " + "Camera capture timed out after %.2fs; lifecycle recovery required", + self.capture_timeout_sec, + self.capture_timeout_sec, + ) + return None + self._pending_capture_job = None try: image = request.make_array("main") self._last_metadata = dict(request.get_metadata() or {}) - self._collect_lores_stats(request) + self._collect_lores_stats(request, main_image=image) finally: request.release() @@ -802,6 +1127,7 @@ def capture_image(self) -> Optional[np.ndarray]: return image except Exception as e: + self._pending_capture_job = None logger.error(f"捕获图像失败: {e}") return None @@ -995,6 +1321,7 @@ def set_auto_exposure(self, enabled: bool) -> bool: try: self.auto_exposure = enabled if enabled: + self._reset_autonomous_ae_state() self._apply_polar_auto_exposure_controls() else: self.camera.set_controls({"AeEnable": False}) @@ -1123,11 +1450,18 @@ def get_camera_info(self) -> dict[str, Any]: camera_properties = self.camera.camera_properties metadata = self._last_metadata or {} capabilities = self._camera_capabilities() + optics = IMX327_16MM_F14_OPTICS.describe_capture( + capture_width_px=self.capture_width, + capture_height_px=self.capture_height, + sampling_mode=self.sampling_mode, + rotation_deg=self.rotation, + ) return { "driver": self.driver_name, "backend": self.backend_name, "capabilities": capabilities, "sensor": camera_properties.get("Model", "Unknown"), + "optics": optics, "resolution": f"{self.width}x{self.height}", "fps": self.fps, "exposure_us": self.exposure_us, @@ -1138,11 +1472,19 @@ def get_camera_info(self) -> dict[str, Any]: "frame_duration_limits": list( self._frame_duration_limits or self._compute_frame_duration_limits() ), + "frame_duration_control": self._frame_duration_control, + "frame_duration_control_error": self._frame_duration_control_error, "analogue_gain": self.analogue_gain, + "actual_analogue_gain": metadata.get( + "AnalogueGain", 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, + "tuning_source": self._tuning_source, + "tuning_loaded": self._tuning_loaded, + "tuning_error": self._tuning_error, "ae_flicker_mode": self.ae_flicker_mode, "noise_reduction_mode": self.noise_reduction_mode, "noise_reduction": {"off": 0, "fast": 1, "high_quality": 2}.get( @@ -1173,6 +1515,14 @@ def get_camera_info(self) -> dict[str, Any]: "night_mode": self.night_mode, "ae_polar_preset": self.ae_polar_preset, "ae_exposure_value": self.ae_exposure_value, + "ae_effective_exposure_value": self._ae_effective_exposure_value, + "ae_aggressive_enabled": self.ae_aggressive_enabled, + "ae_scene_mode": self._ae_scene_mode, + "ae_control_backend": self._ae_control_backend, + "ae_requested_exposure_mode": self._ae_requested_exposure_mode, + "ae_requested_constraint_mode": self._ae_requested_constraint_mode, + "ae_applied_controls": self._ae_applied_controls, + "ae_control_error": self._ae_control_error, "lores_enabled": self.lores_enabled, "lores_available": self._lores_available, "lores_width": self.lores_width, @@ -1295,7 +1645,9 @@ def set_auto_exposure_max_us(self, value: int) -> bool: if not self.is_initialized: logger.error("相机未初始化") return False - self.auto_exposure_max_us = max(10_000, min(10_000_000, int(value))) + self.auto_exposure_max_us = max( + 10_000, min(self.AUTO_EXPOSURE_MAX_US, int(value)) + ) self._apply_frame_duration_controls() return True diff --git a/ogscope/platform/hardware/tuning/imx327.json b/ogscope/platform/hardware/tuning/imx327.json new file mode 100644 index 0000000..fa8068b --- /dev/null +++ b/ogscope/platform/hardware/tuning/imx327.json @@ -0,0 +1,227 @@ +{ + "version": 2.0, + "target": "bcm2835", + "description": "OGScope 产品自维护 IMX327 + 16mm F1.4 tuning,星空曲线优先快门并扩展至 1 秒 / OGScope product-owned IMX327 + 16mm F1.4 tuning with shutter-first starfield exposure up to one second.", + "algorithms": [ + { + "rpi.black_level": + { + "black_level": 3840 + } + }, + { + "rpi.dpc": { } + }, + { + "rpi.lux": + { + "reference_shutter_speed": 6813, + "reference_gain": 1.0, + "reference_aperture": 1.0, + "reference_lux": 890, + "reference_Y": 12900 + } + }, + { + "rpi.noise": + { + "reference_constant": 0, + "reference_slope": 2.67 + } + }, + { + "rpi.geq": + { + "offset": 187, + "slope": 0.00842 + } + }, + { + "rpi.sdn": { } + }, + { + "rpi.awb": + { + "bayes": 0 + } + }, + { + "rpi.agc": + { + "speed": 0.2, + "metering_modes": + { + "matrix": + { + "weights": + [ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 + ] + }, + "centre-weighted": + { + "weights": + [ + 3, 3, 3, 2, 2, 2, 2, 1, 1, 1, 1, 0, 0, 0, 0 + ] + }, + "spot": + { + "weights": + [ + 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 + ] + } + }, + "exposure_modes": + { + "normal": + { + "shutter": [ 10, 30000, 60000 ], + "gain": [ 1.0, 2.0, 8.0 ] + }, + "short": + { + "shutter": [ 10, 5000, 10000, 20000, 120000 ], + "gain": [ 1.0, 2.0, 4.0, 6.0, 8.0 ] + }, + "long": + { + "shutter": [ 1000, 30000, 60000, 120000, 250000, 500000, 1000000, 1000000, 1000000, 1000000 ], + "gain": [ 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.0, 4.0, 4.0 ] + } + }, + "constraint_modes": + { + "normal": [ ], + "highlight": [ + { + "bound": "LOWER", + "q_lo": 0.98, + "q_hi": 1.0, + "y_target": + [ + 0, 0.5, + 1000, 0.5 + ] + }, + { + "bound": "UPPER", + "q_lo": 0.98, + "q_hi": 1.0, + "y_target": + [ + 0, 0.8, + 1000, 0.8 + ] + } + ], + "shadows": [ + { + "bound": "LOWER", + "q_lo": 0.0, + "q_hi": 0.5, + "y_target": + [ + 0, 0.17, + 1000, 0.17 + ] + } + ] + }, + "y_target": + [ + 0, 0.16, + 1000, 0.16, + 10000, 0.16 + ] + } + }, + { + "rpi.alsc": + { + "omega": 1.3, + "n_iter": 100, + "luminance_strength": 0.7, + "luminance_lut": + [ + 2.844, 2.349, 2.018, 1.775, 1.599, 1.466, 1.371, 1.321, 1.306, 1.316, 1.357, 1.439, 1.552, 1.705, 1.915, 2.221, + 2.576, 2.151, 1.851, 1.639, 1.478, 1.358, 1.272, 1.231, 1.218, 1.226, 1.262, 1.335, 1.438, 1.571, 1.766, 2.067, + 2.381, 2.005, 1.739, 1.545, 1.389, 1.278, 1.204, 1.166, 1.153, 1.161, 1.194, 1.263, 1.356, 1.489, 1.671, 1.943, + 2.242, 1.899, 1.658, 1.481, 1.329, 1.225, 1.156, 1.113, 1.096, 1.107, 1.143, 1.201, 1.289, 1.423, 1.607, 1.861, + 2.152, 1.831, 1.602, 1.436, 1.291, 1.193, 1.121, 1.069, 1.047, 1.062, 1.107, 1.166, 1.249, 1.384, 1.562, 1.801, + 2.104, 1.795, 1.572, 1.407, 1.269, 1.174, 1.099, 1.041, 1.008, 1.029, 1.083, 1.146, 1.232, 1.364, 1.547, 1.766, + 2.104, 1.796, 1.572, 1.403, 1.264, 1.171, 1.097, 1.036, 1.001, 1.025, 1.077, 1.142, 1.231, 1.363, 1.549, 1.766, + 2.148, 1.827, 1.594, 1.413, 1.276, 1.184, 1.114, 1.062, 1.033, 1.049, 1.092, 1.153, 1.242, 1.383, 1.577, 1.795, + 2.211, 1.881, 1.636, 1.455, 1.309, 1.214, 1.149, 1.104, 1.081, 1.089, 1.125, 1.184, 1.273, 1.423, 1.622, 1.846, + 2.319, 1.958, 1.698, 1.516, 1.362, 1.262, 1.203, 1.156, 1.137, 1.142, 1.171, 1.229, 1.331, 1.484, 1.682, 1.933, + 2.459, 2.072, 1.789, 1.594, 1.441, 1.331, 1.261, 1.219, 1.199, 1.205, 1.232, 1.301, 1.414, 1.571, 1.773, 2.052, + 2.645, 2.206, 1.928, 1.728, 1.559, 1.451, 1.352, 1.301, 1.282, 1.289, 1.319, 1.395, 1.519, 1.685, 1.904, 2.227 + ], + "sigma": 0.005, + "sigma_Cb": 0.005 + } + }, + { + "rpi.contrast": + { + "ce_enable": 1, + "gamma_curve": + [ + 0, 0, + 1024, 5040, + 2048, 9338, + 3072, 12356, + 4096, 15312, + 5120, 18051, + 6144, 20790, + 7168, 23193, + 8192, 25744, + 9216, 27942, + 10240, 30035, + 11264, 32005, + 12288, 33975, + 13312, 35815, + 14336, 37600, + 15360, 39168, + 16384, 40642, + 18432, 43379, + 20480, 45749, + 22528, 47753, + 24576, 49621, + 26624, 51253, + 28672, 52698, + 30720, 53796, + 32768, 54876, + 36864, 57012, + 40960, 58656, + 45056, 59954, + 49152, 61183, + 53248, 62355, + 57344, 63419, + 61440, 64476, + 65535, 65535 + ] + } + }, + { + "rpi.sharpen": { } + }, + { + "rpi.ccm": + { + "ccms": [ + { + "ct": 3900, + "ccm": + [ + 1.54659, -0.17707, -0.36953, + -0.51471, 1.72733, -0.21262, + 0.06667, -0.92279, 1.85612 + ] + } + ] + } + } + ] +} diff --git a/ogscope/vendor/tetra3/proto/cedar_detect.proto b/ogscope/vendor/tetra3/proto/cedar_detect.proto deleted file mode 120000 index dfa0615..0000000 --- a/ogscope/vendor/tetra3/proto/cedar_detect.proto +++ /dev/null @@ -1 +0,0 @@ -../../../cedar-detect/src/proto/cedar_detect.proto \ No newline at end of file diff --git a/ogscope/web/api/core/routes.py b/ogscope/web/api/core/routes.py index 53152d6..b6f888c 100644 --- a/ogscope/web/api/core/routes.py +++ b/ogscope/web/api/core/routes.py @@ -2,9 +2,13 @@ Core v1 标准契约路由 / Core v1 standard contract routes. """ -from fastapi import APIRouter, HTTPException +import logging + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import StreamingResponse from ogscope.core.application import core_contract_service +from ogscope.domain.camera.streaming import build_camera_mjpeg_stream from ogscope.domain.shared.filesystem import ensure_safe_basename from ogscope.web.api.models.schemas import ( CoreAnalysisControlResponse, @@ -19,6 +23,12 @@ ) router = APIRouter() +logger = logging.getLogger(__name__) + +_MJPEG_LIMIT_DETAIL = ( + "MJPEG stream limit reached; close other previews or tabs / " + "已达到 MJPEG 同时连接上限,请关闭其他标签页的预览" +) @router.post( @@ -92,6 +102,33 @@ async def core_camera_status() -> CoreCameraStatusResponse: raise HTTPException(status_code=500, detail=str(exc)) from exc +@router.get("/core/v1/camera/preview/stream") +async def core_camera_preview_stream( + request: Request, + quality: int | None = Query(None, ge=10, le=100), +) -> StreamingResponse: + """产品级 MJPEG 相机预览 / Product MJPEG camera preview.""" + try: + from ogscope.config import get_settings + + effective_quality = int(quality or get_settings().preview_jpeg_quality) + return await build_camera_mjpeg_stream( + request, + image_format="jpeg", + quality=effective_quality, + limit_detail=_MJPEG_LIMIT_DETAIL, + timeout_log_message=( + "Core MJPEG 单帧取流超时,结束响应以释放名额 / " + "Core MJPEG frame fetch timed out, closing stream" + ), + logger=logger, + ) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + @router.post("/core/v1/camera/start", response_model=CoreCameraControlResponse) async def core_camera_start() -> CoreCameraControlResponse: """启动相机(Core 标准契约)/ Start camera (Core contract).""" diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index 320b480..4cdbdc9 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -175,6 +175,46 @@ async def get_debug_camera_preview( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/debug/camera/focus/metrics") +async def get_debug_camera_focus_metrics( + target_x: float | None = Query(default=None, ge=0.0, le=1.0), + target_y: float | None = Query(default=None, ge=0.0, le=1.0), +): + """测量星点焦点质量,坐标为归一化画幅位置 / Measure focus with normalized target coordinates.""" + if (target_x is None) != (target_y is None): + raise HTTPException( + status_code=422, + detail="target_x and target_y must be provided together / 目标坐标必须成对提供", + ) + try: + return await camera_domain_service.get_focus_metrics( + target_x=target_x, + target_y=target_y, + ) + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/debug/camera/focus/session/start") +async def start_debug_camera_focus_session(): + """锁定焦点测量相机参数 / Lock camera controls for stable focus measurement.""" + try: + return await DebugCameraService.start_focus_session() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@router.post("/debug/camera/focus/session/stop") +async def stop_debug_camera_focus_session(): + """恢复焦点测量前相机参数 / Restore controls from before focus measurement.""" + try: + return await DebugCameraService.stop_focus_session() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + @router.post("/debug/camera/capture") async def capture_debug_image(): """拍摄单张图片 / Take a single picture""" diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index a01dc6a..5ef398c 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -35,6 +35,9 @@ recording_media_filename: Optional[str] = None recording_codec_fourcc: str = "MJPG" recording_container: str = "AVI" +# 焦点会话只锁定会影响星点轮廓的运行时参数,结束时恢复 / Focus session restores runtime imaging controls. +focus_session_snapshot: Optional[dict[str, Any]] = None +focus_session_state_lock: Optional[asyncio.Lock] = None _CAMERA_ENV_KEY_MAP = { "width": "OGSCOPE_CAMERA_WIDTH", @@ -67,6 +70,14 @@ def _get_recording_state_lock() -> asyncio.Lock: return recording_state_lock +def _get_focus_session_state_lock() -> asyncio.Lock: + """懒加载焦点会话锁 / Lazy-init focus-session lock.""" + global focus_session_state_lock + if focus_session_state_lock is None: + focus_session_state_lock = asyncio.Lock() + return focus_session_state_lock + + def is_recording_active() -> bool: """是否正在录制 / Whether recording is active.""" return bool(is_recording) @@ -326,6 +337,8 @@ async def stop_camera(): """停止调试相机 / Stop debugging camera""" camera = await asyncio.to_thread(get_camera_instance) _attach_manager_camera_if_needed(camera) + if focus_session_snapshot is not None: + await DebugCameraService.stop_focus_session() await get_camera_manager().stop() return {"success": True, **i18n_payload("server.cameraStopped", "相机停止成功")} @@ -915,7 +928,12 @@ async def set_auto_exposure_mode(enabled: bool): if not hasattr(camera, "set_auto_exposure"): raise Exception("当前相机不支持自动曝光切换") - if not camera.set_auto_exposure(bool(enabled)): + applied = await get_camera_manager().reconfigure_camera( + "set_auto_exposure", + lambda: camera.set_auto_exposure(bool(enabled)), + timeout_sec=10.0, + ) + if not applied: raise Exception("设置自动曝光模式失败") get_camera_manager().update_runtime_overrides({"auto_exposure": bool(enabled)}) @@ -933,70 +951,77 @@ async def update_settings(settings: dict[str, Any]): raise Exception("相机未初始化") try: - # 优先处理自动曝光开关,避免自动 / Prioritize the automatic exposure switch to avoid automatic - auto_exposure = settings.get( - "autoExposure", getattr(camera, "auto_exposure", False) - ) - if hasattr(camera, "set_auto_exposure"): - camera.set_auto_exposure(bool(auto_exposure)) - - # 更新基础相机参数 / Update basic camera parameters - if not auto_exposure and "exposure" in settings: - camera.set_exposure(settings["exposure"]) - - if not auto_exposure and "gain" in settings and "digitalGain" in settings: - camera.set_gain(settings["gain"], settings.get("digitalGain", 1.0)) - elif not auto_exposure and "gain" in settings: - camera.set_gain(settings["gain"]) - - # 更新图像增强参数 / Update image enhancement parameters - if any( - key in settings - for key in ["contrast", "brightness", "saturation", "sharpness"] - ): - contrast = settings.get("contrast", 1.0) - brightness = settings.get("brightness", 0.0) - saturation = settings.get("saturation", 1.0) - sharpness = settings.get("sharpness", 1.0) + # 整组设置在同一个相机事务中执行,避免与 capture_request 并发访问 libcamera。 + # Apply the full settings batch in one camera transaction to avoid racing capture_request. + def _apply_settings_sync() -> bool: + auto_exposure = settings.get( + "autoExposure", getattr(camera, "auto_exposure", False) + ) + if hasattr(camera, "set_auto_exposure"): + camera.set_auto_exposure(bool(auto_exposure)) - if hasattr(camera, "set_image_enhancement"): + if not auto_exposure and "exposure" in settings: + camera.set_exposure(settings["exposure"]) + + if ( + not auto_exposure + and "gain" in settings + and "digitalGain" in settings + ): + camera.set_gain(settings["gain"], settings.get("digitalGain", 1.0)) + elif not auto_exposure and "gain" in settings: + camera.set_gain(settings["gain"]) + + if any( + key in settings + for key in ["contrast", "brightness", "saturation", "sharpness"] + ) and hasattr(camera, "set_image_enhancement"): camera.set_image_enhancement( - contrast, brightness, saturation, sharpness + settings.get("contrast", 1.0), + settings.get("brightness", 0.0), + settings.get("saturation", 1.0), + settings.get("sharpness", 1.0), ) - # 更新降噪设置 / Update noise reduction settings - if "noiseReductionMode" in settings: - if hasattr(camera, "set_noise_reduction_mode"): + if "noiseReductionMode" in settings and 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"): + elif "noiseReduction" in settings and 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"): + if "aeFlickerMode" in settings and 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"): + if ( + "autoExposureMaxUs" in settings + and settings["autoExposureMaxUs"] + and 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"] - gain_r = settings.get("whiteBalanceGainR", 1.0) - gain_b = settings.get("whiteBalanceGainB", 1.0) + if "whiteBalanceMode" in settings and hasattr( + camera, "set_white_balance" + ): + camera.set_white_balance( + settings["whiteBalanceMode"], + settings.get("whiteBalanceGainR", 1.0), + settings.get("whiteBalanceGainB", 1.0), + ) - if hasattr(camera, "set_white_balance"): - camera.set_white_balance(mode, gain_r, gain_b) + if "colorMode" in settings and hasattr(camera, "set_color_mode"): + camera.set_color_mode(settings["colorMode"]) + return True - # 更新颜色模式设置 / Update color mode settings - if "colorMode" in settings: - if hasattr(camera, "set_color_mode"): - await get_camera_manager().reconfigure_camera( - "update_color_mode", - lambda: camera.set_color_mode(settings["colorMode"]), - timeout_sec=10.0, - ) + logging.getLogger(__name__).info( + "camera_settings_apply fields=%s", ",".join(sorted(settings)) + ) + await get_camera_manager().reconfigure_camera( + "update_settings", _apply_settings_sync, timeout_sec=10.0 + ) overrides: dict[str, Any] = {} if "exposure" in settings: @@ -1040,6 +1065,104 @@ async def update_settings(settings: dict[str, Any]): except Exception as e: raise Exception(f"更新设置失败: {str(e)}") + @staticmethod + async def start_focus_session() -> dict[str, Any]: + """锁定曝光/增益并关闭降噪,保存可恢复快照 / Lock imaging controls with a restorable snapshot.""" + global focus_session_snapshot + async with _get_focus_session_state_lock(): + if focus_session_snapshot is not None: + return { + "success": True, + "active": True, + "already_active": True, + "locked": focus_session_snapshot["locked"], + } + + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + info = ( + camera.get_camera_info() if hasattr(camera, "get_camera_info") else {} + ) + snapshot_settings = { + "autoExposure": bool(getattr(camera, "auto_exposure", False)), + "exposure": int(getattr(camera, "exposure_us", 10_000)), + "gain": float(getattr(camera, "analogue_gain", 1.0)), + "digitalGain": float(getattr(camera, "digital_gain", 1.0)), + "noiseReductionMode": str( + getattr(camera, "noise_reduction_mode", "fast") + ), + } + locked = { + "autoExposure": False, + "exposure": max( + 1, + int( + info.get("actual_exposure_us") or snapshot_settings["exposure"] + ), + ), + "gain": max( + 0.1, + float( + info.get("actual_analogue_gain") or snapshot_settings["gain"] + ), + ), + "digitalGain": max( + 0.1, + float( + info.get("actual_digital_gain") + or snapshot_settings["digitalGain"] + ), + ), + "noiseReductionMode": "off", + } + runtime_overrides = get_camera_manager().get_runtime_overrides() + await DebugCameraService.update_settings(locked) + focus_session_snapshot = { + "settings": snapshot_settings, + "runtime_overrides": runtime_overrides, + "locked": locked, + } + return { + "success": True, + "active": True, + "already_active": False, + "locked": locked, + } + + @staticmethod + async def stop_focus_session() -> dict[str, Any]: + """恢复焦点会话前的相机参数 / Restore camera controls from before focus mode.""" + global focus_session_snapshot + async with _get_focus_session_state_lock(): + snapshot = focus_session_snapshot + if snapshot is None: + return {"success": True, "active": False, "restored": False} + + camera = get_camera_instance() + restored = bool(camera and camera.is_initialized) + if restored: + restore_settings = snapshot["settings"] + if restore_settings["autoExposure"]: + # 先恢复手动基线,再重新开启 AE;否则批量设置会按 AE 语义跳过曝光/增益。 + # Restore the manual baseline before re-enabling AE; AE batches skip exposure/gain. + await DebugCameraService.update_settings( + {**restore_settings, "autoExposure": False} + ) + await DebugCameraService.update_settings({"autoExposure": True}) + else: + await DebugCameraService.update_settings(restore_settings) + manager = get_camera_manager() + manager.clear_runtime_overrides() + manager.update_runtime_overrides(snapshot["runtime_overrides"]) + focus_session_snapshot = None + return { + "success": True, + "active": False, + "restored": restored, + "restored_settings": snapshot["settings"], + } + @staticmethod async def reset_camera(): """重置相机到默认设置 / Reset camera to default settings""" diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 9295ad9..d6da659 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -226,12 +226,14 @@ class SolveContextQuality(BaseModel): gps_valid: bool = False time_valid: bool = False + time_fresh: Optional[bool] = None heading_valid: bool = False mount_valid: bool = False + camera_pose_calibrated: Optional[bool] = None class SolveContextPayload(BaseModel): - """Optional sensor context from ZenitAPA / ZenitAPA 提供的可选传感器上下文。""" + """外部系统提供的可选传感器上下文 / Optional external sensor context.""" model_config = ConfigDict(extra="forbid") @@ -566,7 +568,13 @@ class CoreStreamStatusResponse(BaseModel): max_clients: int active_clients: int frame_fetch_timeout_ms: int + client_stall_timeout_ms: int = 0 target_preview_fps: int + oldest_client_age_ms: int = 0 + oldest_client_idle_ms: int = 0 + released_clients_total: int = 0 + stalled_clients_total: int = 0 + release_reasons: dict[str, int] = Field(default_factory=dict) sensor_target_fps: float = 0.0 preview_target_fps: int = 0 actual_capture_fps: float = 0.0 diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 70224fe..77650e9 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -3,6 +3,8 @@ """ import asyncio +import json +import time from collections.abc import AsyncGenerator from contextlib import asynccontextmanager from copy import deepcopy @@ -235,6 +237,26 @@ def _is_local_client(request: Request) -> bool: return client_host in {"127.0.0.1", "::1", "localhost"} +@app.middleware("http") +async def _diagnostic_access_log(request: Request, call_next): + """Log mutations and failed requests without flooding on status polling / 记录写操作与失败,避免状态轮询刷屏。""" + started = time.monotonic() + response = await call_next(request) + if request.method not in {"GET", "HEAD", "OPTIONS"} or response.status_code >= 400: + payload = { + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": int((time.monotonic() - started) * 1000), + } + log = logger.warning if response.status_code >= 400 else logger.info + log( + "http_event {}", + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + ) + return response + + @app.middleware("http") async def _guard_subordinate_dev_routes(request: Request, call_next): """在 subordinate 角色下限制 dev 接口访问来源 / Limit dev routes in subordinate mode.""" diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py index 9b94944..409ed88 100644 --- a/ogscope/web/camera_shared.py +++ b/ogscope/web/camera_shared.py @@ -43,6 +43,7 @@ def __init__(self) -> None: self._read_lock = Lock() self._frame_lock = Lock() self._grabber_task: asyncio.Task | None = None + self._inflight_read_future: asyncio.Future | None = None self._idle_shutdown_task: asyncio.Task | None = None self._frame_id = 0 self._capture_sequence = 0 @@ -62,15 +63,24 @@ def __init__(self) -> None: 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._capture_timeout_sec = max(0.5, float(settings.camera_capture_timeout_sec)) + self._grabber_drain_timeout_sec = self._capture_timeout_sec + 1.0 + # 停止预算必须覆盖仍在收敛的最长抓帧,否则一次正常的 1 秒 AE 首帧超时 + # 会把 libcamera 留在不可回收状态。 / Stop must outlive an in-flight + # long-AE convergence frame or a recoverable startup becomes unrecoverable. + self._stop_timeout_sec = max(4.0, self._capture_timeout_sec + 2.0) + self._close_timeout_sec = 2.5 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._restart_required = False self._consecutive_grab_failures = 0 self._stream_started_at = 0.0 self._last_capture_success_mono = 0.0 + self._has_successful_capture = False self._preview_consumers = 0 self._analysis_consumers = 0 self._recording_consumers = 0 @@ -109,9 +119,16 @@ 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, + "ae_aggressive_enabled": settings.camera_ae_aggressive_enabled, + "tuning_file": ( + str(settings.camera_tuning_file) + if getattr(settings, "camera_tuning_file", None) + else None + ), "auto_exposure_max_us": getattr( - settings, "camera_auto_exposure_max_us", 2_000_000 + settings, "camera_auto_exposure_max_us", 1_000_000 ), + "capture_timeout_sec": self._capture_timeout_sec, "ae_flicker_mode": getattr(settings, "camera_ae_flicker_mode", "off"), "noise_reduction_mode": getattr( settings, "camera_noise_reduction_mode", "fast" @@ -187,11 +204,14 @@ def _read_frame_sync(self): now = time.monotonic() self._capture_sequence += 1 self._last_capture_success_mono = now + self._has_successful_capture = True self._capture_timestamps.append(now) return frame def _camera_is_fresh(self) -> bool: """判断运行中的相机是否仍有新鲜帧 / Check whether a running camera is still fresh.""" + if self._restart_required: + return False if self._camera is None or not getattr(self._camera, "is_capturing", False): return False if self._last_capture_success_mono <= 0: @@ -216,18 +236,30 @@ def _cancel_idle_shutdown(self) -> None: async def ensure_started(self, *, start_grabber: bool = False) -> None: """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" self._cancel_idle_shutdown() + if self._restart_required: + raise RuntimeError( + self._health_error + or "相机需要重启服务后恢复 / Camera service restart required" + ) 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._restart_required: + raise RuntimeError( + self._health_error + or "相机需要重启服务后恢复 / Camera service restart required" + ) if self._camera_is_fresh(): if start_grabber: await self._ensure_grabber_locked() return if self._camera is None: self._health_error = None + self._restart_required = False + self._has_successful_capture = False self._camera = await asyncio.to_thread(self._create_camera_sync) if self._camera is None: self._health_error = "相机初始化失败 / Camera init failed" @@ -322,60 +354,98 @@ async def stop(self) -> None: ) return try: - await self._stop_grabber_locked() + if not await self._stop_grabber_locked(): + return if self._camera is None: + self._restart_required = False return - try: - await asyncio.wait_for( - asyncio.to_thread(self._safe_stop_capture_sync), timeout=4.0 - ) - except asyncio.TimeoutError: - self._logger.warning( - "相机停止超时,继续执行退出流程 / Camera stop timed out, continue shutdown" - ) - try: - await asyncio.wait_for( - asyncio.to_thread(self._safe_close_camera_sync), timeout=2.5 - ) - except asyncio.TimeoutError: - self._logger.warning( - "相机关闭超时,继续执行退出流程 / Camera close timed out, continue shutdown" + _, stop_timed_out = await self._run_lifecycle_step_locked( + "stop", self._safe_stop_capture_sync, self._stop_timeout_sec + ) + if stop_timed_out: + return + close_ok, close_timed_out = await self._run_lifecycle_step_locked( + "close", self._safe_close_camera_sync, self._close_timeout_sec + ) + if close_timed_out or not close_ok: + self._mark_restart_required( + "相机资源未能安全关闭,需要重启服务 / " + "Camera resources did not close safely; service restart required" ) - self._camera = None - self._last_capture_success_mono = 0.0 - with self._frame_lock: - self._latest_raw = None - self._latest_jpeg = None - self._latest_ts = 0.0 - self._latest_w = 0 - self._latest_h = 0 + return + self._clear_camera_state_locked(health_error=None) finally: if acquired: self._control_lock.release() - def _safe_stop_capture_sync(self) -> None: + def _safe_stop_capture_sync(self) -> bool: camera = self._camera if camera is None: - return + return True if not getattr(camera, "is_capturing", False): - return + return True try: - camera.stop_capture() + return bool(camera.stop_capture()) except Exception as e: self._logger.warning( "停止相机捕获异常 / Failed to stop camera capture: %s", e ) + return False - def _safe_close_camera_sync(self) -> None: + def _safe_close_camera_sync(self) -> bool: camera = self._camera if camera is None: - return + return True try: inner_camera = getattr(camera, "camera", None) if inner_camera is not None and hasattr(inner_camera, "close"): inner_camera.close() + return True except Exception as e: self._logger.warning("关闭相机资源异常 / Failed to close camera: %s", e) + return False + + def _mark_restart_required(self, reason: str) -> None: + """标记不可在进程内恢复的相机故障 / Mark a camera fault unsafe for in-process recovery.""" + self._restart_required = True + self._health_error = reason + self._logger.critical("camera_restart_required reason=%s", reason) + + def _clear_camera_state_locked(self, *, health_error: str | None) -> None: + """清除已确认释放的相机状态 / Clear camera state only after confirmed release.""" + self._camera = None + self._restart_required = False + self._health_error = health_error + self._consecutive_grab_failures = 0 + self._stream_started_at = 0.0 + self._last_capture_success_mono = 0.0 + self._has_successful_capture = False + with self._frame_lock: + self._latest_raw = None + self._latest_jpeg = None + self._latest_ts = 0.0 + self._latest_w = 0 + self._latest_h = 0 + + async def _run_lifecycle_step_locked( + self, + operation_name: str, + fn: Callable[[], bool], + timeout_sec: float, + ) -> tuple[bool, bool]: + """在读锁内限时执行生命周期步骤 / Run a bounded lifecycle step under the read lock.""" + try: + result = await asyncio.wait_for( + asyncio.to_thread(self._call_with_read_lock, fn), + timeout=max(0.05, float(timeout_sec)), + ) + return bool(result), False + except asyncio.TimeoutError: + self._mark_restart_required( + f"相机{operation_name}超时,需要重启服务 / " + f"Camera {operation_name} timed out; service restart required" + ) + return False, True async def pause_grabber(self) -> None: """暂停共享抓帧任务(保留采集)/ Pause shared frame grabber only.""" @@ -405,16 +475,28 @@ async def reconfigure_camera( """受控重配置:同一临界区内停抓帧->改参->恢复 / Controlled reconfigure.""" async with self._control_lock: t0 = time.time() - await self._stop_grabber_locked() + if not await self._stop_grabber_locked(): + raise RuntimeError( + self._health_error + or "相机抓帧任务未能停止 / Camera grabber did not stop" + ) try: result = await asyncio.wait_for( asyncio.to_thread(self._call_with_read_lock, fn), timeout=timeout_sec, ) return result + except asyncio.TimeoutError: + self._mark_restart_required( + f"相机重配置超时({operation_name}),需要重启服务 / " + f"Camera reconfiguration timed out ({operation_name}); service restart required" + ) + raise finally: - if self._camera is not None and getattr( - self._camera, "is_capturing", False + if ( + not self._restart_required + and self._camera is not None + and getattr(self._camera, "is_capturing", False) ): await self._ensure_grabber_locked() self._logger.info( @@ -438,39 +520,68 @@ async def _invalidate_camera_locked(self, reason: str) -> None: self._health_error = reason current = asyncio.current_task() if self._grabber_task and self._grabber_task is not current: - await self._stop_grabber_locked() + if not await self._stop_grabber_locked(): + return elif self._grabber_task is current: self._grabber_task = None - await asyncio.to_thread(self._safe_stop_capture_sync) - await asyncio.to_thread(self._safe_close_camera_sync) - self._camera = None - self._consecutive_grab_failures = 0 - self._stream_started_at = 0.0 - with self._frame_lock: - self._latest_raw = None - self._latest_jpeg = None - self._latest_ts = 0.0 - self._latest_w = 0 - self._latest_h = 0 + _, stop_timed_out = await self._run_lifecycle_step_locked( + "stop", self._safe_stop_capture_sync, self._stop_timeout_sec + ) + if stop_timed_out: + return + close_ok, close_timed_out = await self._run_lifecycle_step_locked( + "close", self._safe_close_camera_sync, self._close_timeout_sec + ) + if close_timed_out or not close_ok: + self._mark_restart_required( + "相机失效后资源未能安全关闭,需要重启服务 / " + "Camera invalidation could not release resources; service restart required" + ) + return + self._clear_camera_state_locked(health_error=reason) async def _ensure_grabber_locked(self) -> None: if self._grabber_task and not self._grabber_task.done(): return self._grabber_task = asyncio.create_task(self._grabber_loop()) - async def _stop_grabber_locked(self) -> None: + async def _stop_grabber_locked(self) -> bool: if not self._grabber_task: - return - self._grabber_task.cancel() + return True + task = self._grabber_task + if task is asyncio.current_task(): + self._grabber_task = None + return True + task.cancel() try: - await asyncio.wait_for(self._grabber_task, timeout=2.0) + await asyncio.wait_for(task, timeout=2.0) except asyncio.CancelledError: # 抓帧任务被取消属于正常停止流程,不应向上抛出 # Task cancellation is expected during graceful stop. pass except Exception: pass + inflight = self._inflight_read_future + if inflight is not None and not inflight.done(): + try: + # shield 防止 wait_for 取消仍在工作线程中收尾的读请求。 + # shield keeps wait_for from cancelling the read that is draining in a worker thread. + await asyncio.wait_for( + asyncio.shield(inflight), timeout=self._grabber_drain_timeout_sec + ) + except asyncio.TimeoutError: + self._mark_restart_required( + "相机抓帧线程未能在超时内退出,需要重启服务 / " + "Camera read worker did not drain; service restart required" + ) + self._grabber_task = None + return False + except Exception: + pass + if self._inflight_read_future is inflight: + self._inflight_read_future = None self._grabber_task = None + return True async def _grabber_loop(self) -> None: loop = asyncio.get_running_loop() @@ -479,7 +590,16 @@ async def _grabber_loop(self) -> None: interval = 1.0 / float(max(1, self._target_fps)) t0 = time.time() try: - frame = await asyncio.to_thread(self._read_frame_sync) + read_future = loop.run_in_executor(None, self._read_frame_sync) + self._inflight_read_future = read_future + try: + frame = await asyncio.shield(read_future) + finally: + if ( + read_future.done() + and self._inflight_read_future is read_future + ): + self._inflight_read_future = None if frame is not None: self._consecutive_grab_failures = 0 encode_t0 = time.perf_counter() @@ -544,6 +664,8 @@ def ensure_camera_instance_sync(self): def attach_camera_instance(self, camera: Any) -> None: """注入现有相机实例(测试/兼容)/ Attach existing camera instance (tests/compat).""" self._camera = camera + self._restart_required = False + self._health_error = None async def status(self) -> dict[str, Any]: cam = self._camera @@ -552,19 +674,24 @@ async def status(self) -> dict[str, Any]: "connected": False, "streaming": False, "error": self._health_error or "相机未初始化 / Camera not initialized", + "restart_required": bool(self._restart_required), "runtime_overrides": self._runtime_overrides, } initialized = bool(getattr(cam, "is_initialized", False)) capturing = bool(getattr(cam, "is_capturing", False)) - has_frames = self._frame_id > 0 + # 空闲相机不会持续抓帧;一次成功探测即可证明流已建立,实际使用时 ensure_started 会再次探测。 + # Idle cameras do not grab continuously; one successful probe proves stream setup, + # while ensure_started re-probes before the next real consumer uses it. + has_successful_capture = self._has_successful_capture within_grace = ( self._stream_started_at > 0 and (time.time() - self._stream_started_at) <= self._probe_timeout_sec ) offline = ( - bool(self._health_error) + self._restart_required + or bool(self._health_error) or self._consecutive_grab_failures >= self._max_grab_failures - or (capturing and not has_frames and not within_grace) + or (capturing and not has_successful_capture and not within_grace) ) if offline: reason = self._health_error @@ -577,12 +704,14 @@ async def status(self) -> dict[str, Any]: "connected": False, "streaming": False, "error": reason, + "restart_required": bool(self._restart_required), "runtime_overrides": self._runtime_overrides, } info = await asyncio.to_thread(cam.get_camera_info) return { "connected": initialized and capturing, - "streaming": capturing and (has_frames or within_grace), + "streaming": capturing and (has_successful_capture or within_grace), + "restart_required": False, "info": info, "runtime_overrides": self._runtime_overrides, } diff --git a/pyproject.toml b/pyproject.toml index 54914b0..18eabb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ repository = "https://github.com/your-username/OGScope" documentation = "https://github.com/your-username/OGScope/docs" keywords = ["astronomy", "polar-alignment", "astrophotography", "raspberry-pi", "mipi-camera"] packages = [{include = "ogscope"}] +include = ["ogscope/platform/hardware/tuning/*.json"] [tool.poetry.dependencies] python = "^3.10" diff --git a/scripts/sync_board_code.sh b/scripts/sync_board_code.sh index a01d590..fc245e6 100755 --- a/scripts/sync_board_code.sh +++ b/scripts/sync_board_code.sh @@ -45,7 +45,7 @@ echo "== Sync OGScope code → ${REMOTE}:${DEV_PATH} (uploads/logs/data preserve rsync -avz --delete \ -e "${RSYNC_SSH}" \ - --exclude '.git/' \ + --exclude '.git' \ --exclude '.venv/' \ --exclude 'node_modules/' \ --exclude '__pycache__/' \ diff --git a/tests/unit/test_camera_flip.py b/tests/unit/test_camera_flip.py index 43f714a..fb3925d 100644 --- a/tests/unit/test_camera_flip.py +++ b/tests/unit/test_camera_flip.py @@ -2,6 +2,7 @@ import sys import types +from concurrent.futures import TimeoutError as FutureTimeoutError import numpy as np import pytest @@ -62,6 +63,7 @@ class _FakePicamera2: def __init__(self) -> None: self.controls_log: list[dict] = [] self.camera_controls = {} + self.started = False def create_video_configuration(self, **kwargs): return kwargs @@ -72,6 +74,48 @@ def configure(self, _config) -> None: def set_controls(self, controls: dict) -> None: self.controls_log.append(dict(controls)) + def start(self) -> None: + self.started = True + + +class _FakeCompletedRequest: + """最小完成请求替身 / Minimal completed-request test double.""" + + def __init__(self) -> None: + self.released = False + + def make_array(self, _stream: str) -> np.ndarray: + return np.zeros((720, 1280, 3), dtype=np.uint8) + + def get_metadata(self) -> dict: + return {"ExposureTime": 10000} + + def release(self) -> None: + self.released = True + + +class _AsyncCapturePicamera: + """记录异步抓帧和有限等待 / Track asynchronous capture and bounded waits.""" + + def __init__(self, *, time_out: bool = False) -> None: + self.time_out = time_out + self.capture_calls = 0 + self.wait_timeouts: list[float] = [] + self.job = object() + self.request = _FakeCompletedRequest() + + def capture_request(self, *, wait: bool): + assert wait is False + self.capture_calls += 1 + return self.job + + def wait(self, job, timeout: float): + assert job is self.job + self.wait_timeouts.append(timeout) + if self.time_out: + raise FutureTimeoutError + return self.request + @pytest.mark.unit def test_initialize_auto_white_balance_really_enables_awb(monkeypatch) -> None: @@ -88,6 +132,50 @@ def test_initialize_auto_white_balance_really_enables_awb(monkeypatch) -> None: assert any(item.get("AwbEnable") is True for item in fake.controls_log) +@pytest.mark.unit +def test_initialize_auto_exposure_does_not_seed_manual_controls(monkeypatch) -> None: + """自动模式不得锁入手动初值 / Auto mode must not latch manual seed controls.""" + fake = _FakePicamera2() + monkeypatch.setitem( + sys.modules, + "picamera2", + types.SimpleNamespace(Picamera2=lambda: fake), + ) + cam = IMX327MIPICamera( + _minimal_config( + auto_exposure=True, + exposure_us=10_000, + analogue_gain=2.0, + digital_gain=1.5, + ) + ) + + assert cam.initialize() is True + auto_writes = [item for item in fake.controls_log if item.get("AeEnable") is True] + assert auto_writes + assert all("ExposureTime" not in item for item in auto_writes) + assert all("AnalogueGain" not in item for item in auto_writes) + assert all("DigitalGain" not in item for item in auto_writes) + + +@pytest.mark.unit +def test_start_capture_reapplies_auto_exposure_after_stream_start(monkeypatch) -> None: + """相机开始出帧后再次启用 AE / Re-enable AE after streaming starts.""" + fake = _FakePicamera2() + cam = IMX327MIPICamera(_minimal_config(auto_exposure=True)) + cam.camera = fake + cam.is_initialized = True + applied_after_start: list[bool] = [] + monkeypatch.setattr( + cam, + "_apply_polar_auto_exposure_controls", + lambda: applied_after_start.append(fake.started), + ) + + assert cam.start_capture() is True + assert applied_after_start == [True] + + @pytest.mark.unit def test_initialize_manual_white_balance_sets_colour_gains(monkeypatch) -> None: fake = _FakePicamera2() @@ -112,6 +200,41 @@ def test_initialize_manual_white_balance_sets_colour_gains(monkeypatch) -> None: ) +@pytest.mark.unit +def test_capture_uses_picamera_job_with_bounded_wait() -> None: + fake = _AsyncCapturePicamera() + cam = IMX327MIPICamera(_minimal_config(capture_timeout_sec=3.5)) + cam.camera = fake + cam.is_initialized = True + cam.is_capturing = True + + frame = cam.capture_image() + + assert frame is not None + assert fake.capture_calls == 1 + assert fake.wait_timeouts == [3.5] + assert fake.request.released is True + assert cam._pending_capture_job is None + + +@pytest.mark.unit +def test_capture_timeout_reuses_pending_job_instead_of_queueing_another() -> None: + fake = _AsyncCapturePicamera(time_out=True) + cam = IMX327MIPICamera(_minimal_config(capture_timeout_sec=0.5)) + cam.camera = fake + cam.is_initialized = True + cam.is_capturing = True + + assert cam.capture_image() is None + assert fake.capture_calls == 1 + assert cam._pending_capture_job is fake.job + + fake.time_out = False + assert cam.capture_image() is not None + assert fake.capture_calls == 1 + assert cam._pending_capture_job is None + + @pytest.mark.unit def test_encode_frame_preserves_rgb_channel_order() -> None: cv2 = pytest.importorskip("cv2") @@ -144,20 +267,61 @@ def test_preview_encoder_falls_back_to_opencv_when_turbojpeg_missing( @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=1_000_000) + ) + + assert cam._compute_frame_duration_limits() == (125_000, 1_000_000) + + +@pytest.mark.unit +def test_auto_exposure_ceiling_is_capped_at_one_second() -> 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) + assert cam.auto_exposure_max_us == 1_000_000 + assert cam._compute_frame_duration_limits() == (125_000, 1_000_000) + + +@pytest.mark.unit +def test_aggressive_ae_ignores_small_clipped_highlight() -> None: + fake = _FakePicamera2() + fake.camera_controls = {"ExposureValue": (-2.0, 2.0, 0.0)} + cam = IMX327MIPICamera(_minimal_config(auto_exposure=True, ae_exposure_value=1.0)) + cam.camera = fake + + cam._update_aggressive_auto_exposure( + {"p50": 20.0, "p90": 35.0, "p99": 80.0, "saturated_fraction": 0.01} + ) + + assert fake.controls_log[-1]["ExposureValue"] == pytest.approx(0.5) + assert cam._ae_effective_exposure_value == pytest.approx(0.5) + + +@pytest.mark.unit +def test_aggressive_ae_returns_toward_neutral_in_bright_scene() -> None: + fake = _FakePicamera2() + fake.camera_controls = {"ExposureValue": (-2.0, 2.0, 0.0)} + cam = IMX327MIPICamera(_minimal_config(auto_exposure=True, ae_exposure_value=1.0)) + cam.camera = fake + cam._ae_effective_exposure_value = 1.5 + + cam._update_aggressive_auto_exposure( + {"p50": 220.0, "p90": 250.0, "p99": 255.0, "saturated_fraction": 0.25} + ) + + assert fake.controls_log[-1]["ExposureValue"] == pytest.approx(0.0) + assert cam._ae_effective_exposure_value == pytest.approx(0.0) @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) + _minimal_config(fps=8, auto_exposure=False, exposure_us=500_000) ) - assert cam._compute_frame_duration_limits() == (250_000, 250_000) + assert cam._compute_frame_duration_limits() == (500_000, 500_000) @pytest.mark.unit diff --git a/tests/unit/test_camera_manager_health.py b/tests/unit/test_camera_manager_health.py index 738c830..1e3b372 100644 --- a/tests/unit/test_camera_manager_health.py +++ b/tests/unit/test_camera_manager_health.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import time import numpy as np import pytest @@ -38,6 +39,28 @@ def get_video_frame(self): return np.zeros((360, 640, 3), dtype=np.uint8) +class _CloseProbe: + def __init__(self) -> None: + self.closed = False + + def close(self) -> None: + self.closed = True + + +class _SlowStopCamera(_FrameCamera): + """模拟底层 stop 暂时卡住 / Simulate a temporarily stuck low-level stop.""" + + def __init__(self) -> None: + super().__init__() + self.camera = _CloseProbe() + self.is_capturing = True + + def stop_capture(self) -> bool: + time.sleep(0.08) + self.is_capturing = False + return True + + @pytest.mark.asyncio async def test_ensure_started_fails_when_no_frames() -> None: manager = CameraManager() @@ -67,6 +90,48 @@ async def test_ensure_started_succeeds_when_frames_available() -> None: await manager.stop() +@pytest.mark.asyncio +async def test_status_uses_successful_raw_probe_without_jpeg_grabber() -> None: + """冷启动探测帧应直接建立流健康状态 / Cold-start raw probe must establish stream health.""" + manager = CameraManager() + manager._probe_timeout_sec = 0.5 + camera = _FrameCamera() + manager.attach_camera_instance(camera) + + await manager.ensure_started() + assert manager._frame_id == 0 + + status = await manager.status() + assert status["connected"] is True + assert status["streaming"] is True + await manager.stop() + + +@pytest.mark.asyncio +async def test_stale_camera_reprobe_restores_status_without_jpeg_grabber() -> None: + """过期采集重新探测后应一次恢复 / A stale capture should recover after one re-probe.""" + manager = CameraManager() + manager._probe_timeout_sec = 0.5 + manager._stale_timeout_sec = 0.5 + camera = _FrameCamera() + manager.attach_camera_instance(camera) + + await manager.ensure_started() + manager._last_capture_success_mono -= 2.0 + + idle_status = await manager.status() + assert idle_status["connected"] is True + assert idle_status["streaming"] is True + + await manager.ensure_started() + + status = await manager.status() + assert camera.read_count == 2 + assert status["connected"] is True + 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.""" @@ -104,3 +169,59 @@ def test_preview_fps_is_independent_runtime_setting() -> None: manager = CameraManager() assert manager.set_preview_fps(12) == 12 assert manager._target_fps == 12 + + +def test_lifecycle_stop_budget_outlives_long_exposure_capture() -> None: + """停止预算必须覆盖长曝光抓帧收敛 / Stop budget must outlive long-AE capture.""" + manager = CameraManager() + + assert manager._capture_timeout_sec >= 8.0 + assert manager._stop_timeout_sec >= manager._capture_timeout_sec + 2.0 + + +@pytest.mark.asyncio +async def test_stop_timeout_keeps_camera_handle_and_blocks_reacquire() -> None: + """stop 超时后不得丢弃仍占用 libcamera 的实例 / Keep the handle after stop timeout.""" + manager = CameraManager() + camera = _SlowStopCamera() + manager.attach_camera_instance(camera) + manager._stop_timeout_sec = 0.01 + + await manager.stop() + + assert manager.get_camera_instance() is camera + status = await manager.status() + assert status["connected"] is False + assert status["restart_required"] is True + with pytest.raises(RuntimeError, match="restart required|重启服务"): + await manager.ensure_started() + + # 让首次 stop 工作线程收尾,再确认一次显式 stop 可以安全释放。 + # Let the first stop worker drain, then verify an explicit retry can release safely. + await asyncio.sleep(0.1) + manager._stop_timeout_sec = 0.2 + await manager.stop() + assert manager.get_camera_instance() is None + assert camera.camera.closed is True + + +@pytest.mark.asyncio +async def test_reconfigure_timeout_marks_restart_required_and_stops_grabber() -> None: + """重配置超时后不得恢复抓帧 / Do not resume the grabber after reconfigure timeout.""" + manager = CameraManager() + camera = _FrameCamera() + manager.attach_camera_instance(camera) + camera.is_capturing = True + + def _slow_reconfigure() -> bool: + time.sleep(0.08) + return True + + with pytest.raises(asyncio.TimeoutError): + await manager.reconfigure_camera( + "slow_test", _slow_reconfigure, timeout_sec=0.01 + ) + + assert manager._restart_required is True + assert manager._grabber_task is None + await asyncio.sleep(0.1) diff --git a/tests/unit/test_camera_optics.py b/tests/unit/test_camera_optics.py new file mode 100644 index 0000000..0386eb2 --- /dev/null +++ b/tests/unit/test_camera_optics.py @@ -0,0 +1,55 @@ +"""产品光学模型测试 / Product optics model tests.""" + +from __future__ import annotations + +import pytest + +from ogscope.camera_optics import IMX327_16MM_F14_OPTICS + + +@pytest.mark.unit +def test_imx327_16mm_profile_separates_full_and_effective_fov() -> None: + """全幅名义视场不能冒充 720p 有效视场 / Full nominal FOV cannot replace 720p effective FOV.""" + optics = IMX327_16MM_F14_OPTICS.describe_capture( + capture_width_px=1280, + capture_height_px=720, + sampling_mode="native", + rotation_deg=180, + ) + + assert optics["sensor"] == { + "model": "IMX327", + "pixel_pitch_um": 2.9, + "recording_width_px": 1920, + "recording_height_px": 1080, + } + assert optics["lens"] == { + "focal_length_mm": 16.0, + "aperture_f_number": 1.4, + "resolution_rating_mp": 5.0, + "mount": "M12", + "ir_cut_filter": True, + } + assert optics["full_sensor_fov_deg"]["width"] == pytest.approx(19.74) + assert optics["full_sensor_fov_deg"]["height"] == pytest.approx(11.18) + assert optics["theoretical_effective_fov_deg"]["width"] == pytest.approx(13.23) + assert optics["effective_fov_deg"] == { + "width": 13.01, + "height": 7.34, + "source": "product_calibrated", + } + + +@pytest.mark.unit +def test_supersample_uses_full_sensor_region_and_rotation_swaps_axes() -> None: + """全幅超采样保留视场且旋转交换输出轴 / Supersampling preserves FOV; rotation swaps axes.""" + optics = IMX327_16MM_F14_OPTICS.describe_capture( + capture_width_px=1280, + capture_height_px=720, + sampling_mode="supersample", + rotation_deg=90, + ) + + assert optics["effective_sensor_region_px"] == {"width": 1920, "height": 1080} + assert optics["effective_fov_deg"]["width"] == pytest.approx(11.0, abs=0.02) + assert optics["effective_fov_deg"]["height"] == pytest.approx(19.42, abs=0.02) diff --git a/tests/unit/test_camera_tuning.py b/tests/unit/test_camera_tuning.py new file mode 100644 index 0000000..fc55b99 --- /dev/null +++ b/tests/unit/test_camera_tuning.py @@ -0,0 +1,271 @@ +"""IMX327 产品 tuning 与曝光诊断测试 / IMX327 product tuning and exposure diagnostics tests.""" + +from __future__ import annotations + +import json +from typing import Any + +import numpy as np +import pytest + +from ogscope.platform.hardware.camera import IMX327MIPICamera + + +def _camera(**extra: Any) -> IMX327MIPICamera: + config: dict[str, Any] = { + "width": 1280, + "height": 720, + "fps": 8, + "auto_exposure": True, + } + config.update(extra) + return IMX327MIPICamera(config) + + +@pytest.mark.unit +def test_product_tuning_long_curve_reaches_one_second() -> None: + """产品曲线优先延长曝光并最终到达 1 秒 / Product curve prioritizes shutter and reaches 1s.""" + with IMX327MIPICamera.PRODUCT_TUNING_FILE.open(encoding="utf-8") as file: + tuning = json.load(file) + + agc = next(item["rpi.agc"] for item in tuning["algorithms"] if "rpi.agc" in item) + long_mode = agc["exposure_modes"]["long"] + + assert long_mode["shutter"][-1] == 1_000_000 + assert len(long_mode["shutter"]) == len(long_mode["gain"]) + first_max_shutter = long_mode["shutter"].index(1_000_000) + assert long_mode["gain"][: first_max_shutter + 1] == [1.0] * (first_max_shutter + 1) + assert max(long_mode["gain"]) == 4.0 + assert agc["constraint_modes"]["shadows"][0]["q_hi"] == pytest.approx(0.5) + + +@pytest.mark.unit +def test_autonomous_ae_selects_libcamera_long_shadows_for_starfield( + monkeypatch, +) -> None: + """暗场确认后必须请求 Long+Shadows / Confirmed darkness must request Long+Shadows.""" + + class Constraint: + Normal = "constraint-normal" + Shadows = "constraint-shadows" + + class Metering: + Matrix = "metering-matrix" + + class Exposure: + Normal = "exposure-normal" + Long = "exposure-long" + + class Namespace: + AeConstraintModeEnum = Constraint + AeMeteringModeEnum = Metering + AeExposureModeEnum = Exposure + + class FakeCamera: + camera_controls = { + "AeConstraintMode": object(), + "AeMeteringMode": object(), + "AeExposureMode": object(), + "ExposureValue": object(), + "FrameDurationLimits": object(), + } + + def __init__(self) -> None: + self.controls: list[dict[str, Any]] = [] + + def set_controls(self, controls: dict[str, Any]) -> None: + self.controls.append(controls) + + camera = _camera(ae_exposure_value=1.0) + fake = FakeCamera() + camera.camera = fake + monkeypatch.setattr( + camera, + "_load_ae_control_namespace", + lambda: (Namespace, "test.libcamera.controls"), + ) + dark = { + "p50": 8.0, + "p90": 10.0, + "p99": 12.0, + "p99_8": 13.0, + "saturated_fraction": 0.0, + } + + camera._update_aggressive_auto_exposure(dark) + camera._ae_last_adjust_at = 0.0 + camera._update_aggressive_auto_exposure(dark) + + scene_update = next(item for item in fake.controls if "AeExposureMode" in item) + assert camera._ae_scene_mode == "starfield" + assert scene_update["AeExposureMode"] == "exposure-long" + assert scene_update["AeConstraintMode"] == "constraint-shadows" + assert camera._ae_control_backend == "test.libcamera.controls" + + +@pytest.mark.unit +def test_autonomous_ae_immediately_escapes_severe_brightness(monkeypatch) -> None: + """严重过曝必须立即退回日间曲线 / Severe clipping must immediately restore daylight AE.""" + + class Constraint: + Normal = "constraint-normal" + Shadows = "constraint-shadows" + + class Metering: + Matrix = "metering-matrix" + + class Exposure: + Normal = "exposure-normal" + Long = "exposure-long" + + class Namespace: + AeConstraintModeEnum = Constraint + AeMeteringModeEnum = Metering + AeExposureModeEnum = Exposure + + class FakeCamera: + camera_controls = { + "AeConstraintMode": object(), + "AeMeteringMode": object(), + "AeExposureMode": object(), + "ExposureValue": object(), + "FrameDurationLimits": object(), + } + + def __init__(self) -> None: + self.controls: list[dict[str, Any]] = [] + + def set_controls(self, controls: dict[str, Any]) -> None: + self.controls.append(controls) + + camera = _camera(ae_exposure_value=1.0) + fake = FakeCamera() + camera.camera = fake + camera._ae_scene_mode = "starfield" + camera._ae_effective_exposure_value = 1.5 + monkeypatch.setattr( + camera, "_load_ae_control_namespace", lambda: (Namespace, "test") + ) + + camera._update_aggressive_auto_exposure( + { + "p50": 220.0, + "p90": 250.0, + "p99": 255.0, + "p99_8": 255.0, + "saturated_fraction": 0.25, + } + ) + + scene_update = next(item for item in fake.controls if "AeExposureMode" in item) + assert camera._ae_scene_mode == "daylight" + assert scene_update["AeExposureMode"] == "exposure-long" + assert scene_update["AeConstraintMode"] == "constraint-normal" + assert camera._ae_effective_exposure_value == pytest.approx(0.0) + + +@pytest.mark.unit +def test_product_tuning_loader_reports_bundled_source() -> None: + """产品 tuning 成功时记录可诊断来源 / Report the bundled source after a successful load.""" + + class FakePicamera2: + @staticmethod + def load_tuning_file(name: str, *, dir: str) -> dict[str, str]: + assert name == "imx327.json" + assert dir.endswith("hardware/tuning") + return {"loaded": name} + + camera = _camera() + tuning = camera._load_picamera_tuning(FakePicamera2) + + assert tuning == {"loaded": "imx327.json"} + assert camera._tuning_source == "product:imx327.json" + assert camera._tuning_loaded is True + assert camera._tuning_error is None + + +@pytest.mark.unit +def test_tuning_loader_falls_back_without_exposing_override_path(tmp_path) -> None: + """覆盖文件失败时安全回退且 API 状态不泄露路径 / Fall back without exposing override paths.""" + + class MissingTuningPicamera2: + @staticmethod + def load_tuning_file(name: str, *, dir: str) -> dict[str, str]: + raise FileNotFoundError(f"{dir}/{name}") + + secret_path = tmp_path / "private-camera-tuning.json" + camera = _camera(tuning_file=str(secret_path)) + + assert camera._load_picamera_tuning(MissingTuningPicamera2) is None + assert camera._tuning_source == "system_default" + assert camera._tuning_loaded is False + assert camera._tuning_error == "FileNotFoundError" + assert str(tmp_path) not in camera._tuning_error + + +@pytest.mark.unit +def test_histogram_stats_use_lores_and_include_highlight_tail() -> None: + """亮度统计使用有界直方图并保留高光尾部 / Use bounded histograms and retain highlight-tail data.""" + + class Request: + @staticmethod + def make_array(stream: str) -> np.ndarray: + assert stream == "lores" + return np.array([[0, 10, 20, 30], [40, 50, 250, 255]], dtype=np.uint8) + + camera = _camera(lores_width=4, lores_height=2) + camera._lores_available = True + camera._collect_lores_stats(Request()) + + stats = camera._last_lores_stats + assert stats["source"] == "lores" + assert stats["sample_count"] == 8 + assert stats["p50"] == 30.0 + assert stats["p99_8"] == 255.0 + assert stats["saturated_fraction"] == pytest.approx(0.25) + + +@pytest.mark.unit +def test_histogram_stats_fall_back_to_bounded_main_stream() -> None: + """无 lores 时仅有界采样主流绿通道 / Sample a bounded main-stream green channel without lores.""" + camera = _camera() + camera._lores_available = False + image = np.zeros((720, 1280, 3), dtype=np.uint8) + image[..., 1] = 64 + + camera._collect_lores_stats(object(), main_image=image) + + stats = camera._last_lores_stats + assert stats["source"] == "main_fallback" + assert stats["sample_count"] <= camera.LUMINANCE_STATS_MAX_SAMPLES + assert stats["p50"] == 64.0 + + +@pytest.mark.unit +def test_frame_duration_control_reports_primary_and_fallback_paths() -> None: + """帧周期控制必须明确报告主路径和降级路径 / Report primary and fallback frame-duration paths.""" + + class FakeCamera: + camera_controls = {"FrameDurationLimits": object()} + + def __init__(self) -> None: + self.fail_limits = False + self.controls: list[dict[str, Any]] = [] + + def set_controls(self, controls: dict[str, Any]) -> None: + if self.fail_limits and "FrameDurationLimits" in controls: + raise RuntimeError("unsupported at runtime") + self.controls.append(controls) + + camera = _camera() + fake = FakeCamera() + camera.camera = fake + + camera._apply_frame_duration_controls() + assert camera._frame_duration_control == "frame_duration_limits" + + fake.fail_limits = True + camera._apply_frame_duration_controls() + assert camera._frame_duration_control == "frame_rate_fallback" + assert fake.controls[-1] == {"FrameRate": 8.0} + assert camera._frame_duration_control_error == "RuntimeError" diff --git a/tests/unit/test_config_catalog.py b/tests/unit/test_config_catalog.py index 8fc0254..d48c289 100644 --- a/tests/unit/test_config_catalog.py +++ b/tests/unit/test_config_catalog.py @@ -17,6 +17,7 @@ def test_build_config_catalog_includes_new_preview_fields() -> None: assert "OGSCOPE_SHARED_PREVIEW_FPS" in keys assert "OGSCOPE_PREVIEW_JPEG_QUALITY" in keys assert "OGSCOPE_PREVIEW_ENCODER" in keys + assert "OGSCOPE_CAMERA_TUNING_FILE" 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 @@ -38,3 +39,11 @@ def test_build_config_catalog_includes_new_preview_fields() -> None: def test_simulation_mode_tri_state(raw: str | None, expected: bool | None) -> None: settings = Settings(simulation_mode=raw) # type: ignore[arg-type] assert settings.simulation_mode is expected + + +@pytest.mark.unit +def test_legacy_auto_exposure_ceiling_is_capped_at_one_second() -> None: + """旧环境值不阻止启动且会收敛到 1 秒 / Legacy values boot and clamp to 1s.""" + settings = Settings(camera_auto_exposure_max_us=2_000_000) + + assert settings.camera_auto_exposure_max_us == 1_000_000 diff --git a/tests/unit/test_core_contract_api.py b/tests/unit/test_core_contract_api.py index 86cf914..30e7ccf 100644 --- a/tests/unit/test_core_contract_api.py +++ b/tests/unit/test_core_contract_api.py @@ -71,6 +71,13 @@ def test_core_camera_ambient_hint_from_metadata() -> None: "actual_exposure_us": 80_000, "auto_exposure_max_us": 100_000, "actual_digital_gain": 2.0, + "optics": { + "effective_fov_deg": { + "width": 13.01, + "height": 7.34, + "source": "product_calibrated", + } + }, }, } ) @@ -78,10 +85,103 @@ def test_core_camera_ambient_hint_from_metadata() -> None: hint = normalized["ambient_hint"] assert hint["available"] is True assert hint["source"] == "camera_metadata" + assert normalized["info"]["optics"]["effective_fov_deg"]["width"] == 13.01 assert 0.0 <= hint["dark_score"] <= 1.0 assert hint["exposure_us"] == 80_000 +@pytest.mark.unit +@pytest.mark.asyncio +async def test_core_camera_start_requires_confirmed_ready(monkeypatch) -> None: + """启动成功仍须确认真实流就绪 / A successful command still requires stream readiness.""" + from ogscope.core.application import core_service + + events: list[str] = [] + + class _HardwareClient: + async def device_command(self, service: str, command: str) -> dict: + assert (service, command) == ("camera", "start") + events.append("hardware_plane") + return {"success": True} + + async def _start() -> dict: + events.append("camera_domain") + return {"success": True, "message": "start accepted"} + + async def _not_ready() -> dict: + return { + "success": True, + "connected": True, + "streaming": False, + "info": {"model": "IMX327"}, + "error": "camera did not report a valid stream", + } + + service = core_service.CoreContractService() + monkeypatch.setattr(core_service, "get_hardware_plane_client", _HardwareClient) + monkeypatch.setattr(core_service.camera_domain_service, "start", _start) + monkeypatch.setattr(service, "get_camera_status", _not_ready) + + result = await service.start_camera() + + assert events == ["camera_domain", "hardware_plane"] + assert result["success"] is False + assert result["info"]["model"] == "IMX327" + assert result["applied"] == { + "action": "start", + "hardware_plane_ok": True, + "ready": False, + "connected": True, + "streaming": False, + } + assert result["message"] == "camera did not report a valid stream" + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_core_camera_tune_writes_manual_baseline_before_enabling_ae( + monkeypatch, +) -> None: + """同时恢复 AE 时先写手动基线 / Combined AE restore writes the manual baseline first.""" + from ogscope.core.application import core_service + + events: list[tuple[str, object]] = [] + + async def _update_settings(settings: dict) -> dict: + events.append(("settings", settings)) + return {"success": True} + + async def _set_auto_exposure(enabled: bool) -> dict: + events.append(("auto_exposure", enabled)) + return {"success": True} + + async def _status() -> dict: + return {"info": {"exposure_us": 600_000, "auto_exposure": True}} + + monkeypatch.setattr( + core_service.camera_domain_service, "update_settings", _update_settings + ) + monkeypatch.setattr( + core_service.camera_domain_service, + "set_auto_exposure_mode", + _set_auto_exposure, + ) + monkeypatch.setattr(core_service.camera_domain_service, "get_status", _status) + + result = await core_service.CoreContractService().tune_camera( + {"exposure_us": 600_000, "auto_exposure": True} + ) + + assert events == [ + ("settings", {"exposure": 600_000, "autoExposure": False}), + ("auto_exposure", True), + ] + assert result["applied"] == { + "exposure_us": 600_000, + "auto_exposure": True, + } + + @pytest.mark.unit def test_core_system_status_network_delegated_when_subordinate(monkeypatch) -> None: """subordinate 下 network 标记 delegated 且不降级 / Subordinate marks network delegated.""" @@ -102,8 +202,10 @@ def test_core_analysis_lifecycle(client, monkeypatch) -> None: """开始-查询-结束分析生命周期 / Start-result-stop lifecycle.""" from ogscope.core.application import core_service + received: dict = {} + async def _fake_start(**kwargs): # noqa: ANN003 - _ = kwargs + received.update(kwargs) return {"success": True, "message": "started"} async def _fake_status(): @@ -122,20 +224,44 @@ async def _fake_stop(): monkeypatch.setattr(core_service.realtime_solve_service, "get_status", _fake_status) monkeypatch.setattr(core_service.realtime_solve_service, "stop", _fake_stop) - start = client.post("/api/core/v1/analysis/start", json={}) + start = client.post( + "/api/core/v1/analysis/start", + json={ + "solve_context": { + "quality": { + "gps_valid": True, + "time_valid": True, + "time_fresh": False, + "heading_valid": True, + "mount_valid": True, + "camera_pose_calibrated": False, + } + } + }, + ) assert start.status_code == 200 assert start.json()["state"] == "running" + assert received["session_id"] == start.json()["session_id"] + assert received["solve_context"].quality.time_fresh is False + assert received["solve_context"].quality.camera_pose_calibrated is False + first_session_id = start.json()["session_id"] result = client.get("/api/core/v1/analysis/result") assert result.status_code == 200 body = result.json() assert body["state"] == "running" assert body["result"]["status"] == "MATCH_FOUND" + assert body["session_id"] == first_session_id stop = client.post("/api/core/v1/analysis/stop", json={}) assert stop.status_code == 200 assert stop.json()["state"] == "stopped" + restarted = client.post("/api/core/v1/analysis/start", json={}) + assert restarted.status_code == 200 + assert restarted.json()["session_id"] != first_session_id + client.post("/api/core/v1/analysis/stop", json={}) + @pytest.mark.unit def test_core_camera_contract_endpoints(client, monkeypatch) -> None: @@ -252,6 +378,31 @@ def test_core_video_info_rejects_invalid_filename(client) -> None: assert resp.json()["detail"] == "invalid filename" +@pytest.mark.unit +def test_core_camera_preview_stream_is_product_contract(client, monkeypatch) -> None: + """Core 预览应暴露稳定 MJPEG 契约 / Core preview exposes stable MJPEG contract.""" + from fastapi.responses import StreamingResponse + + from ogscope.web.api.core import routes as core_routes + + async def _fake_stream(*_args, **_kwargs): + async def _frames(): + yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\ntest\r\n" + + return StreamingResponse( + _frames(), + media_type="multipart/x-mixed-replace; boundary=frame", + ) + + monkeypatch.setattr(core_routes, "build_camera_mjpeg_stream", _fake_stream) + + resp = client.get("/api/core/v1/camera/preview/stream?quality=75") + + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("multipart/x-mixed-replace") + assert b"Content-Type: image/jpeg" in resp.content + + @pytest.mark.unit def test_docs_are_split_between_core_and_dev(client) -> None: """文档默认 core,dev 单独入口 / Docs split into core default and dev page.""" @@ -288,6 +439,7 @@ def test_core_openapi_contains_required_business_endpoints(client) -> None: "/api/core/v1/analysis/result", "/api/core/v1/analysis/stop", "/api/core/v1/camera/status", + "/api/core/v1/camera/preview/stream", } assert required.issubset(paths) assert all(path.startswith("/api/core/v1/") for path in paths) diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py index a99f487..b74911a 100644 --- a/tests/unit/test_debug_camera_api.py +++ b/tests/unit/test_debug_camera_api.py @@ -42,8 +42,11 @@ def get_camera_info(self): "auto_exposure": self.auto_exposure, "white_balance_mode": self.white_balance_mode, "exposure_us": self.exposure_us, + "actual_exposure_us": 24000, "analogue_gain": self.analogue_gain, + "actual_analogue_gain": 2.5, "digital_gain": self.digital_gain, + "actual_digital_gain": 1.3, "noise_reduction_mode": self.noise_reduction_mode, "ae_flicker_mode": self.ae_flicker_mode, "auto_exposure_max_us": self.auto_exposure_max_us, @@ -155,6 +158,8 @@ async def _noop(): return None get_camera_manager().attach_camera_instance(camera) + monkeypatch.setattr(debug_services, "focus_session_snapshot", None) + monkeypatch.setattr(debug_services, "focus_session_state_lock", None) monkeypatch.setattr(debug_services, "get_camera_instance", _get_camera_instance) monkeypatch.setattr( debug_services.DebugCameraService, @@ -238,6 +243,99 @@ def test_debug_camera_image_quality_success(client, fake_camera_env): assert body["quality"]["noise_level"] == 0.1 +@pytest.mark.unit +def test_debug_camera_focus_metrics_success(client, fake_camera_env, monkeypatch): + from ogscope.domain.camera import services as camera_services + + async def _focus_metrics(*, target_x=None, target_y=None): + return { + "success": True, + "state": "measuring", + "frame_id": 10, + "timestamp": 1.0, + "frame": {"width": 640, "height": 360}, + "stars_detected": 4, + "stars_measured": 4, + "stars_used": 4, + "aggregate": { + "median_hfd_px": 2.4, + "hfd_mad_px": 0.2, + "median_fwhm_px": 2.5, + "median_concentration": 0.7, + }, + "selected_star": None, + "stars": [], + "warnings": [], + } + + monkeypatch.setattr( + camera_services.camera_domain_service, + "get_focus_metrics", + _focus_metrics, + ) + response = client.get( + "/api/dev/debug/camera/focus/metrics", + params={"target_x": 0.5, "target_y": 0.5}, + ) + + assert response.status_code == 200 + assert response.json()["aggregate"]["median_hfd_px"] == 2.4 + + +@pytest.mark.unit +def test_debug_camera_focus_metrics_requires_coordinate_pair(client): + response = client.get( + "/api/dev/debug/camera/focus/metrics", params={"target_x": 0.5} + ) + + assert response.status_code == 422 + + +@pytest.mark.unit +def test_debug_camera_focus_session_locks_and_restores_settings( + client, fake_camera_env +): + """焦点会话固定实测曝光并恢复原设置 / Focus session locks actual exposure and restores.""" + start = client.post("/api/dev/debug/camera/focus/session/start") + + assert start.status_code == 200, start.text + assert start.json()["locked"]["exposure"] == 24000 + assert fake_camera_env.auto_exposure is False + assert fake_camera_env.exposure_us == 24000 + assert fake_camera_env.analogue_gain == pytest.approx(2.5) + assert fake_camera_env.digital_gain == pytest.approx(1.3) + assert fake_camera_env.noise_reduction_mode == "off" + + repeated = client.post("/api/dev/debug/camera/focus/session/start") + assert repeated.json()["already_active"] is True + + stop = client.post("/api/dev/debug/camera/focus/session/stop") + assert stop.status_code == 200 + assert stop.json()["restored"] is True + assert fake_camera_env.auto_exposure is True + assert fake_camera_env.exposure_us == 10000 + assert fake_camera_env.analogue_gain == pytest.approx(1.0) + assert fake_camera_env.digital_gain == pytest.approx(1.0) + assert fake_camera_env.noise_reduction_mode == "fast" + + +@pytest.mark.unit +def test_debug_camera_stop_restores_active_focus_session(client, fake_camera_env): + """停止相机也应恢复焦点会话参数 / Stopping camera also restores focus settings.""" + start = client.post("/api/dev/debug/camera/focus/session/start") + assert start.status_code == 200, start.text + assert fake_camera_env.auto_exposure is False + + stop = client.post("/api/dev/debug/camera/stop") + + assert stop.status_code == 200, stop.text + assert fake_camera_env.auto_exposure is True + assert fake_camera_env.exposure_us == 10000 + assert fake_camera_env.analogue_gain == pytest.approx(1.0) + assert fake_camera_env.digital_gain == pytest.approx(1.0) + assert fake_camera_env.noise_reduction_mode == "fast" + + @pytest.mark.unit def test_debug_camera_update_settings_success(client, fake_camera_env): payload = { @@ -252,7 +350,7 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): "noiseReduction": 1, "noiseReductionMode": "high_quality", "aeFlickerMode": "50hz", - "autoExposureMaxUs": 1000000, + "autoExposureMaxUs": 500000, "whiteBalanceMode": "auto", "whiteBalanceGainR": 1.0, "whiteBalanceGainB": 1.0, @@ -266,7 +364,7 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): 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 + assert fake_camera_env.auto_exposure_max_us == 500000 @pytest.mark.unit diff --git a/tests/unit/test_domain_camera_streaming.py b/tests/unit/test_domain_camera_streaming.py index 18e3e1c..d081b1e 100644 --- a/tests/unit/test_domain_camera_streaming.py +++ b/tests/unit/test_domain_camera_streaming.py @@ -1,9 +1,14 @@ +"""MJPEG 流生命周期测试 / MJPEG stream lifecycle tests.""" + from __future__ import annotations +import asyncio + import pytest from fastapi import HTTPException from ogscope.domain.camera import streaming as streaming_mod +from ogscope.domain.camera.stream_limiter import MjpegStreamLimiter class _FakeRequest: @@ -19,17 +24,21 @@ async def is_disconnected(self) -> bool: return value -class _FakeLimiter: - def __init__(self, can_acquire: bool = True) -> None: - self.max_clients = 2 - self.active_clients = 0 - self._can_acquire = can_acquire +class _FakeSettings: + stream_mjpeg_frame_fetch_timeout_ms = 1000 + stream_mjpeg_client_stall_timeout_ms = 1000 + + +class _FakeManager: + def __init__(self) -> None: + self.acquired = False self.released = False + self.preview_target_fps = 8 - async def try_acquire(self) -> bool: - return self._can_acquire + async def acquire_preview_consumer(self) -> None: + self.acquired = True - async def release(self) -> None: + async def release_preview_consumer(self) -> None: self.released = True @@ -38,7 +47,9 @@ async def release(self) -> None: async def test_build_camera_mjpeg_stream_rejects_when_limit_reached( monkeypatch, ) -> None: - limiter = _FakeLimiter(can_acquire=False) + limiter = MjpegStreamLimiter(1) + held_lease = await limiter.try_acquire() + assert held_lease is not None monkeypatch.setattr(streaming_mod, "get_mjpeg_stream_limiter", lambda: limiter) req = _FakeRequest([False]) @@ -52,31 +63,16 @@ async def test_build_camera_mjpeg_stream_rejects_when_limit_reached( logger=streaming_mod.logging.getLogger(__name__), ) assert exc.value.status_code == 503 + await held_lease.release() @pytest.mark.unit @pytest.mark.asyncio async def test_build_camera_mjpeg_stream_yields_frame_and_releases(monkeypatch) -> None: - limiter = _FakeLimiter(can_acquire=True) + limiter = MjpegStreamLimiter(2) monkeypatch.setattr(streaming_mod, "get_mjpeg_stream_limiter", lambda: limiter) - - class _FakeSettings: - stream_mjpeg_frame_fetch_timeout_ms = 1000 - shared_preview_fps = 8 - monkeypatch.setattr(streaming_mod, "get_settings", lambda: _FakeSettings()) - 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) @@ -92,9 +88,8 @@ async def _fake_get_stream_frame_bytes( _fake_get_stream_frame_bytes, ) - req = _FakeRequest([False, True]) resp = await streaming_mod.build_camera_mjpeg_stream( - req, + _FakeRequest([False, True]), image_format="jpeg", quality=75, limit_detail="limit", @@ -105,6 +100,78 @@ async def _fake_get_stream_frame_bytes( first_chunk = await anext(body_iter) assert b"Content-Type: image/jpeg" in first_chunk await body_iter.aclose() - assert limiter.released is True + + assert limiter.active_clients == 0 assert manager.acquired is True assert manager.released is True + snapshot = await limiter.snapshot() + assert snapshot["release_reasons"] == {"generator_closed": 1} + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_session_releases_slot_before_slow_camera_cleanup() -> None: + limiter = MjpegStreamLimiter(1) + lease = await limiter.try_acquire() + assert lease is not None + cleanup_started = asyncio.Event() + allow_cleanup = asyncio.Event() + + class _SlowReleaseManager(_FakeManager): + async def release_preview_consumer(self) -> None: + cleanup_started.set() + await allow_cleanup.wait() + self.released = True + + manager = _SlowReleaseManager() + session = streaming_mod._MjpegStreamSession( + lease=lease, + manager=manager, + stall_timeout_s=0, + logger=streaming_mod.logging.getLogger(__name__), + path="/test", + ) + assert await session.acquire_preview_consumer() is True + + close_caller = asyncio.create_task(session.close("cancelled")) + await cleanup_started.wait() + assert limiter.active_clients == 0 + close_caller.cancel() + with pytest.raises(asyncio.CancelledError): + await close_caller + + allow_cleanup.set() + for _ in range(20): + if manager.released: + break + await asyncio.sleep(0.01) + assert manager.released is True + + +@pytest.mark.unit +@pytest.mark.asyncio +async def test_session_watchdog_reclaims_stalled_client() -> None: + limiter = MjpegStreamLimiter(1) + lease = await limiter.try_acquire() + assert lease is not None + manager = _FakeManager() + session = streaming_mod._MjpegStreamSession( + lease=lease, + manager=manager, + stall_timeout_s=0.05, + logger=streaming_mod.logging.getLogger(__name__), + path="/test", + ) + assert await session.acquire_preview_consumer() is True + session.start_watchdog() + + for _ in range(30): + if limiter.active_clients == 0 and manager.released: + break + await asyncio.sleep(0.01) + + assert limiter.active_clients == 0 + assert manager.released is True + snapshot = await limiter.snapshot() + assert snapshot["stalled_clients_total"] == 1 + await session.close("duplicate") diff --git a/tests/unit/test_focus_metrics.py b/tests/unit/test_focus_metrics.py new file mode 100644 index 0000000..04d2f74 --- /dev/null +++ b/tests/unit/test_focus_metrics.py @@ -0,0 +1,164 @@ +"""星点焦点测量测试 / Star focus metric tests.""" + +from __future__ import annotations + +import cv2 +import numpy as np +import pytest + +from ogscope.domain.camera.focus import FocusMetricAnalyzer + + +def _star_field( + *, sigma: float, saturated: bool = False, noise_std: float = 0.0 +) -> np.ndarray: + """生成可重复的合成星场 / Build a deterministic synthetic star field.""" + height, width = 240, 320 + yy, xx = np.indices((height, width), dtype=np.float32) + frame = np.full((height, width), 8.0, dtype=np.float32) + stars = [(70, 62, 150.0), (160, 78, 125.0), (245, 135, 180.0), (105, 175, 110.0)] + for x, y, amplitude in stars: + value = 255.0 if saturated and x == 70 else amplitude + frame += value * np.exp(-((xx - x) ** 2 + (yy - y) ** 2) / (2.0 * sigma**2)) + if noise_std > 0: + rng = np.random.default_rng(42) + frame += rng.normal(0.0, noise_std, frame.shape) + frame = np.clip(frame, 0.0, 255.0).astype(np.uint8) + return cv2.cvtColor(frame, cv2.COLOR_GRAY2RGB) + + +@pytest.mark.unit +def test_focus_hfd_increases_with_blur() -> None: + analyzer = FocusMetricAnalyzer(min_snr=4.0) + + sharp = analyzer.analyze(_star_field(sigma=1.4), frame_id=1, timestamp=1.0) + blurred = analyzer.analyze(_star_field(sigma=4.2), frame_id=2, timestamp=2.0) + + assert sharp["state"] == "measuring" + assert blurred["state"] == "measuring" + assert sharp["stars_used"] >= 3 + assert blurred["stars_used"] >= 3 + assert sharp["aggregate"]["median_hfd_px"] < blurred["aggregate"]["median_hfd_px"] + assert ( + sharp["aggregate"]["median_concentration"] + > blurred["aggregate"]["median_concentration"] + ) + + +@pytest.mark.unit +def test_focus_hfd_remains_directional_with_sensor_noise() -> None: + analyzer = FocusMetricAnalyzer(min_snr=4.0) + + sharp = analyzer.analyze( + _star_field(sigma=1.4, noise_std=4.0), frame_id=11, timestamp=11.0 + ) + blurred = analyzer.analyze( + _star_field(sigma=4.2, noise_std=4.0), frame_id=12, timestamp=12.0 + ) + + assert sharp["stars_used"] >= 3 + assert blurred["stars_used"] >= 3 + assert sharp["aggregate"]["median_hfd_px"] < blurred["aggregate"]["median_hfd_px"] + + +@pytest.mark.unit +def test_focus_reports_empty_frame_without_false_score() -> None: + analyzer = FocusMetricAnalyzer() + frame = np.full((120, 160, 3), 10, dtype=np.uint8) + + result = analyzer.analyze(frame, frame_id=3, timestamp=3.0) + + assert result["state"] == "no_stars" + assert result["aggregate"] is None + assert result["selected_star"] is None + assert "no_stars_detected" in result["warnings"] + + +@pytest.mark.unit +def test_focus_selects_nearest_target_star() -> None: + analyzer = FocusMetricAnalyzer(min_snr=4.0) + result = analyzer.analyze( + _star_field(sigma=1.8), + frame_id=4, + timestamp=4.0, + target_x=245 / 320, + target_y=135 / 240, + ) + + selected = result["selected_star"] + assert selected is not None + assert selected["x"] == pytest.approx(245, abs=3) + assert selected["y"] == pytest.approx(135, abs=3) + + +@pytest.mark.unit +def test_focus_rejects_saturated_star_from_aggregate() -> None: + analyzer = FocusMetricAnalyzer(min_snr=4.0) + result = analyzer.analyze( + _star_field(sigma=1.8, saturated=True), frame_id=5, timestamp=5.0 + ) + + assert result["stars_used"] >= 3 + assert "saturated_stars_rejected" in result["warnings"] + + +@pytest.mark.unit +def test_focus_detects_undersampled_stars_with_sensor_noise() -> None: + """默认焦点管线保留噪声中的小星点 / Default focus pipeline keeps tiny noisy stars.""" + analyzer = FocusMetricAnalyzer() + + result = analyzer.analyze( + _star_field(sigma=0.65, noise_std=4.0), + frame_id=6, + timestamp=6.0, + ) + + assert result["state"] == "measuring" + assert result["stars_used"] >= 3 + assert result["detection"]["pipeline"] == "focus_local_sigma_v1" + + undersampled = analyzer.analyze( + _star_field(sigma=0.45), frame_id=61, timestamp=61.0 + ) + assert "undersampled_stars" in undersampled["warnings"] + + +@pytest.mark.unit +def test_focus_clicked_target_bypasses_global_candidate_limit() -> None: + """点击目标直接局部测量,不受全局候选上限影响 / Clicked target bypasses auto limit.""" + height, width = 180, 260 + yy, xx = np.indices((height, width), dtype=np.float32) + frame = np.full((height, width), 8.0, dtype=np.float32) + frame += 180.0 * np.exp(-((xx - 70) ** 2 + (yy - 70) ** 2) / (2.0 * 2.0**2)) + frame += 70.0 * np.exp(-((xx - 200) ** 2 + (yy - 120) ** 2) / (2.0 * 0.8**2)) + rgb = cv2.cvtColor(np.clip(frame, 0.0, 255.0).astype(np.uint8), cv2.COLOR_GRAY2RGB) + analyzer = FocusMetricAnalyzer(max_stars=1) + + automatic = analyzer.analyze(rgb, frame_id=7, timestamp=7.0) + targeted = analyzer.analyze( + rgb, + frame_id=8, + timestamp=8.0, + target_x=200 / width, + target_y=120 / height, + ) + + assert automatic["selected_star"]["x"] == pytest.approx(70, abs=3) + assert targeted["selected_star"] is not None + assert targeted["selected_star"]["source"] == "target" + assert targeted["selected_star"]["x"] == pytest.approx(200, abs=3) + assert targeted["detection"]["target_forced"] is True + + +@pytest.mark.unit +def test_focus_auto_mode_rejects_isolated_hot_pixels() -> None: + """自动模式不把孤立热像素作为焦点星 / Auto mode rejects isolated hot pixels.""" + frame = np.full((180, 260, 3), 8, dtype=np.uint8) + for y, x in ((40, 50), (60, 110), (90, 180), (130, 220)): + frame[y, x, :] = 180 + + result = FocusMetricAnalyzer().analyze(frame, frame_id=9, timestamp=9.0) + + assert result["stars_detected"] == 4 + assert result["stars_used"] == 0 + assert result["detection"]["quality_rejections"]["hot_pixel_like"] == 4 diff --git a/tests/unit/test_mjpeg_stream_limiter.py b/tests/unit/test_mjpeg_stream_limiter.py index f51de15..259cc66 100644 --- a/tests/unit/test_mjpeg_stream_limiter.py +++ b/tests/unit/test_mjpeg_stream_limiter.py @@ -1,38 +1,60 @@ -"""MJPEG 并发限制单元测试 / Unit tests for MJPEG stream limiter.""" +"""MJPEG 会话限制单元测试 / Unit tests for MJPEG stream session limiter.""" from __future__ import annotations +import asyncio + import pytest from ogscope.web.mjpeg_stream_limiter import MjpegStreamLimiter @pytest.mark.asyncio -async def test_limiter_unlimited_always_acquires() -> None: +async def test_limiter_unlimited_tracks_and_releases_leases() -> None: lim = MjpegStreamLimiter(0) - assert await lim.try_acquire() is True - assert await lim.try_acquire() is True - await lim.release() - await lim.release() + first = await lim.try_acquire() + second = await lim.try_acquire() + assert first is not None + assert second is not None + assert lim.active_clients == 2 + + assert await first.release("client_disconnect") is True + assert await first.release("duplicate") is False + assert await second.release("response_complete") is True + assert lim.active_clients == 0 + + snapshot = await lim.snapshot() + assert snapshot["released_clients_total"] == 2 + assert snapshot["release_reasons"] == { + "client_disconnect": 1, + "response_complete": 1, + } @pytest.mark.asyncio -async def test_limiter_one_client_blocks_second() -> None: +async def test_limiter_one_client_blocks_second_until_lease_release() -> None: lim = MjpegStreamLimiter(1) - assert await lim.try_acquire() is True - assert await lim.try_acquire() is False - await lim.release() - assert await lim.try_acquire() is True - await lim.release() + lease = await lim.try_acquire() + assert lease is not None + assert await lim.try_acquire() is None + assert await lease.release() is True + replacement = await lim.try_acquire() + assert replacement is not None + await replacement.release() @pytest.mark.asyncio -async def test_limiter_two_clients_blocks_third() -> None: - lim = MjpegStreamLimiter(2) - assert await lim.try_acquire() is True - assert await lim.try_acquire() is True - assert await lim.try_acquire() is False - await lim.release() - assert await lim.try_acquire() is True - await lim.release() - await lim.release() +async def test_limiter_touch_updates_idle_metric() -> None: + lim = MjpegStreamLimiter(1) + lease = await lim.try_acquire() + assert lease is not None + await asyncio.sleep(0.02) + before = await lim.snapshot() + assert before["oldest_client_idle_ms"] >= 10 + + assert await lease.touch() is True + after = await lim.snapshot() + assert after["oldest_client_idle_ms"] < before["oldest_client_idle_ms"] + await lease.release("client_stall_timeout") + final = await lim.snapshot() + assert final["stalled_clients_total"] == 1 diff --git a/tests/unit/test_realtime_solve_pipeline.py b/tests/unit/test_realtime_solve_pipeline.py new file mode 100644 index 0000000..c3ae914 --- /dev/null +++ b/tests/unit/test_realtime_solve_pipeline.py @@ -0,0 +1,62 @@ +"""Core realtime solve pipeline tests / Core 实时解算管线测试。""" + +from __future__ import annotations + +import json +from unittest.mock import MagicMock, patch + +import numpy as np + +from ogscope.core.realtime.service import RealtimeSolveService + + +def test_core_realtime_uses_authoritative_bgr_pipeline() -> None: + """Core 与开发分析共用 Tetra 图像入口 / Core shares the Tetra image entry.""" + service = RealtimeSolveService() + service.solver = MagicMock() + frame = np.zeros((32, 48, 3), dtype=np.uint8) + + service._solve_frame_sync(frame) + + service.solver.solve_from_bgr_frame.assert_called_once_with( + frame_bgr=frame, + max_stars=service._max_stars, + hint_ra_deg=service._hint_ra, + hint_dec_deg=service._hint_dec, + solve_source="realtime", + fov_estimate=service._fov_estimate, + fov_max_error=service._fov_max_error, + solve_timeout_ms=service._solve_timeout_ms, + ) + service.solver.solve.assert_not_called() + + +def test_completed_realtime_frame_clears_transient_error() -> None: + """新完成帧清除旧瞬时错误 / A completed frame clears an older transient error.""" + service = RealtimeSolveService() + service.state.last_error = "temporary capture error" + solved = MagicMock() + solved.to_dict.return_value = {"status": "NO_MATCH"} + solved.ra_deg = None + solved.dec_deg = None + + service._apply_solve_result(solved) + + assert service.state.last_error == "" + + +def test_analysis_event_carries_session_correlation() -> None: + """结构化事件携带会话关联 ID / Structured events carry the session correlation ID.""" + service = RealtimeSolveService() + service.state.session_id = "solve-123" + service.state.started_mono = 1.0 + + with patch("ogscope.core.realtime.service.logger") as mocked_logger: + service._log_event("fullsolve_finished", status="NO_MATCH", detected_stars=2) + + template, payload_raw = mocked_logger.info.call_args.args + payload = json.loads(payload_raw) + assert template == "analysis_event {}" + assert payload["session_id"] == "solve-123" + assert payload["event"] == "fullsolve_finished" + assert payload["status"] == "NO_MATCH" diff --git a/tests/unit/test_sensor_solve_context.py b/tests/unit/test_sensor_solve_context.py index 6a85fda..9bafd19 100644 --- a/tests/unit/test_sensor_solve_context.py +++ b/tests/unit/test_sensor_solve_context.py @@ -85,3 +85,29 @@ def test_sensor_prediction_unavailable_when_time_invalid() -> None: attach_sensor_prediction(row, context) assert row["sensor_prediction"]["sensor_status"] == "unavailable" + + +def test_sensor_prediction_unavailable_when_camera_pose_is_uncalibrated() -> None: + """未标定机械光轴时不得比较指向 / Uncalibrated boresight cannot be compared.""" + context = _solve_context() + context["quality"]["camera_pose_calibrated"] = False + row = {"status": "MATCH_FOUND", "ra_deg": 180.0, "dec_deg": 0.0} + attach_sensor_prediction(row, context) + + prediction = row["sensor_prediction"] + assert prediction["sensor_status"] == "unavailable" + assert prediction["sensor_reason"] == "camera_pose_uncalibrated" + assert prediction.get("sensor_delta_deg") is None + + +def test_sensor_prediction_unavailable_when_observer_time_is_stale() -> None: + """过期观测时间不得参与预测 / Stale observer time cannot drive prediction.""" + context = _solve_context() + context["quality"]["time_fresh"] = False + row = {"status": "MATCH_FOUND", "ra_deg": 0.0, "dec_deg": 0.0} + attach_sensor_prediction(row, context) + + assert row["status"] == "MATCH_FOUND" + prediction = row["sensor_prediction"] + assert prediction["sensor_status"] == "unavailable" + assert prediction["sensor_reason"] == "observer_time_stale" diff --git a/web/spa/src/apps/camera/CameraConsoleApp.tsx b/web/spa/src/apps/camera/CameraConsoleApp.tsx index 0a1b3c3..863afb3 100644 --- a/web/spa/src/apps/camera/CameraConsoleApp.tsx +++ b/web/spa/src/apps/camera/CameraConsoleApp.tsx @@ -1,13 +1,15 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; import { Camera, Circle, + Crosshair, Download, FileText, FolderOpen, Info, Moon, Play, + RotateCcw, Save, Settings2, SlidersHorizontal, @@ -38,7 +40,9 @@ async function requestJson( type CameraInfo = { exposure_us?: number; + actual_exposure_us?: number; analogue_gain?: number; + actual_analogue_gain?: number; digital_gain?: number; auto_exposure?: boolean; contrast?: number; @@ -57,6 +61,11 @@ type CameraInfo = { colour_temperature?: number; lux?: number; frame_duration_limits?: number[]; + frame_duration_control?: string; + frame_duration_control_error?: string | null; + tuning_source?: string; + tuning_loaded?: boolean; + tuning_error?: string | null; lores_enabled?: boolean; lores_available?: boolean; lores_width?: number; @@ -122,8 +131,59 @@ type StreamMetrics = { cma_free_kb?: number; }; +type FocusStarMetric = { + x: number; + y: number; + hfd_px: number; + fwhm_px: number; + snr: number; + peak_snr: number; + roundness: number; + concentration: number; + aperture_radius_px: number; + saturated: boolean; + undersampled: boolean; + source: "auto" | "target"; +}; + +type FocusMetrics = { + success: boolean; + state: "measuring" | "low_confidence" | "no_stars"; + frame_id: number; + timestamp: number; + frame: { width: number; height: number }; + stars_detected: number; + stars_measured: number; + stars_used: number; + detection: { + pipeline: string; + noise_sigma: number; + threshold: number; + components: number; + rejected_large: number; + rejected_weak: number; + target_forced: boolean; + measurement_rejections: Record; + quality_rejections: Record; + }; + aggregate: { + median_hfd_px: number; + hfd_mad_px: number; + median_fwhm_px: number; + median_concentration: number; + } | null; + selected_star: FocusStarMetric | null; + stars: FocusStarMetric[]; + warnings: string[]; +}; + +type FocusSample = { + frameId: number; + hfd: number; +}; + type CameraForm = { - exposure: number; + exposureSeconds: number; gain: number; digitalGain: number; autoExposure: boolean; @@ -134,7 +194,7 @@ type CameraForm = { noiseReduction: number; noiseReductionMode: string; aeFlickerMode: string; - autoExposureMaxUs: number; + autoExposureMaxSeconds: number; whiteBalanceMode: string; whiteBalanceGainR: number; whiteBalanceGainB: number; @@ -185,6 +245,9 @@ type DebugFileInfo = { const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1020"] as const; const ROTATION_PRESETS = [0, 90, 180, 270] as const; const FILE_PAGE_SIZE = 12; +const MANUAL_EXPOSURE_MIN_SECONDS = 0.0001; +const MANUAL_EXPOSURE_MAX_SECONDS = 1.0; +const MANUAL_EXPOSURE_STEP_SECONDS = 0.0001; function clamp(v: number, min: number, max: number): number { return Math.min(max, Math.max(min, v)); @@ -195,6 +258,27 @@ function toNum(v: unknown, fallback: number): number { return Number(v); } +function exposureUsToSeconds(value: unknown, fallbackUs: number): number { + return toNum(value, fallbackUs) / 1_000_000; +} + +function exposureSecondsToUs(value: number): number { + return Math.round(value * 1_000_000); +} + +function formatExposureSeconds(valueUs: unknown): string { + return `${exposureUsToSeconds(valueUs, 0).toFixed(4)} s`; +} + +function cameraSettingsPayload(form: CameraForm): Record { + const { exposureSeconds, autoExposureMaxSeconds, ...settings } = form; + return { + ...settings, + exposure: exposureSecondsToUs(exposureSeconds), + autoExposureMaxUs: exposureSecondsToUs(autoExposureMaxSeconds), + }; +} + function formatSize(bytes: number): string { if (!bytes) return "0 B"; const units = ["B", "KB", "MB", "GB"]; @@ -207,6 +291,34 @@ function formatSize(bytes: number): string { return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; } +function FocusHistoryChart({ samples, emptyLabel }: { samples: FocusSample[]; emptyLabel: string }) { + if (samples.length < 2) { + return ( +
+ {emptyLabel} +
+ ); + } + const values = samples.map((sample) => sample.hfd); + const min = Math.min(...values); + const max = Math.max(...values); + const spread = Math.max(0.2, max - min); + const points = values + .map((value, index) => { + const x = (index / Math.max(1, values.length - 1)) * 320; + const y = 76 - ((value - min) / spread) * 60; + return `${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(" "); + return ( + + + + + + ); +} + /** * 用 stream/status 探测是否还有 MJPEG 名额(勿 fetch MJPEG URL,否则会额外占用 try_acquire 与长连接重叠)。 * Check limiter via JSON; never fetch the MJPEG URL for probe (that consumes a slot and overlaps with ). @@ -247,12 +359,13 @@ function ParamSlider({ disabled?: boolean; unit?: string; }) { + const decimals = step >= 1 ? 0 : Math.min(6, Math.max(1, Math.ceil(-Math.log10(step)))); return (