diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5803107..76d8f18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,8 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.9", "3.10", "3.11"] + # 与 pyproject.toml 中 python = "^3.10" 一致 / Match Poetry python constraint + python-version: ["3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v4 @@ -37,6 +38,19 @@ jobs: - name: 安装依赖 run: poetry install --no-interaction --no-root + - name: 设置 Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: web/analysis-ui/package-lock.json + + - name: 构建星空解算控制台前端 + run: | + cd web/analysis-ui + npm ci + npm run build + - name: 代码格式检查 (Black) run: poetry run black --check ogscope tests @@ -56,6 +70,7 @@ jobs: file: ./coverage.xml flags: unittests name: codecov-umbrella + fail_ci_if_error: false # 无 CODECOV_TOKEN 的 fork 等场景不阻断 CI / Do not fail CI without token lint: runs-on: ubuntu-latest @@ -65,7 +80,7 @@ jobs: - name: 设置 Python uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.10" - name: 安装 Poetry uses: snok/install-poetry@v1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e68299a..b1bb87f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: - name: 设置 Python uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.10" - name: 安装 Poetry uses: snok/install-poetry@v1 diff --git a/.gitignore b/.gitignore index d42160f..e6f85af 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,15 @@ env.bak/ venv.bak/ .python-version +# ==================== +# Node (星图解算实验室前端 / Analysis lab UI) +# ==================== +web/analysis-ui/node_modules/ + # ==================== # Poetry # ==================== -poetry.lock # 在开发机和 Orange Pi 上可能不一致,建议忽略 +poetry.lock # 在开发机与树莓派上可能不一致,建议忽略 .poetry/ # ==================== @@ -92,6 +97,12 @@ logs/ *.sqlite3 *.db-shm *.db-wal +!data/catalog/stars.db +data/catalog/raw/* +data/catalog/index/* +data/catalog/meta/* +!data/catalog/meta/manifest.json +!data/catalog/README.md # ==================== # 配置文件(敏感信息) @@ -137,6 +148,9 @@ captured_images/ *.fits *.fit +# Tetra3 图案库(体积大,本地或部署时放入 / Large pattern DB; copy on deploy) +data/plate_solve/default_database.npz + # 星表数据(太大,不提交) star_catalogs/*.dat star_catalogs/*.bin diff --git a/CLAUDE.md b/CLAUDE.md index 5f76c13..0227a99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,17 +4,18 @@ ## 项目概述 -OGScope 是一个基于 Orange Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 +OGScope 是一个基于 Raspberry Pi Zero 2W 的电子极轴镜系统,用于天文摄影中的精确极轴校准。 ## 技术栈 -- **硬件**: Orange Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD +- **硬件**: Raspberry Pi Zero 2W, IMX327 相机, 2.4寸 SPI LCD - **语言**: Python 3.9+ - **包管理**: Poetry - **Web 框架**: FastAPI + Uvicorn - **日志**: Loguru - **测试**: Pytest - **代码质量**: Black, Ruff, MyPy +- **星空解算控制台前端**: `web/analysis-ui`(Vite + React + Tailwind),构建输出 `web/static/analysis-lab/`;本地执行 `cd web/analysis-ui && npm install && npm run build`;同步到开发板见 `scripts/sync_dev_board.sh`(环境变量 `OGSCOPE_DEV_HOST`、`OGSCOPE_DEV_PATH` 等)。i18n 见 `web/static/i18n/analysis.zh.json` / `analysis.en.json`。 ## 开发环境 @@ -59,6 +60,7 @@ ogscope/ - 类型提示: 使用 Python 类型注解 - 文档字符串: Google 风格 - 导入顺序: 标准库 → 第三方库 → 本地模块 +- 注释规范: 以后新增或修改注释必须使用中英文双语(中文 / English) ## 测试标记 @@ -67,16 +69,6 @@ ogscope/ - `@pytest.mark.hardware`: 需要实际硬件的测试 - `@pytest.mark.slow`: 运行较慢的测试 -## 配置管理 - -- 默认配置: `default_config.json` -- 环境变量: `.env` (从 `.env.example` 复制) -- 运行时配置: `ogscope/config.py` (Pydantic Settings) - -## 参考项目 - -- **PiFinder**: 板块求解寻星器架构参考 -- **OpenMV Polar Scope**: 极轴镜算法参考 ## 注意事项 @@ -101,12 +93,12 @@ ogscope/ - **虚拟环境**: Poetry 管理 ### 部署配置 -- **生产环境**: Orange Pi Zero 2W 开发板 +- **生产环境**: Raspberry Pi Zero 2W 开发板 - **测试环境**: [与生产环境相同] - **虚拟环境目录**: [用户自定义] ### 系统服务配置 -项目已配置为系统服务,服务配置文件位于 `/etc/systemd/system/ogscope.service`: +在开发板运行时项目已配置为系统服务,服务配置文件位于 `/etc/systemd/system/ogscope.service`: ```ini [Unit] @@ -129,6 +121,15 @@ RestartSec=3 WantedBy=multi-user.target ``` +在本地运行时,使用虚拟环境,因为有些硬件只能在开发板上调用,所以本地只是代码编写,远程测试 + +### WiFi 与网络(NetworkManager) + +- **唯一详解**见 [`docs/development/wifi-nm.md`](docs/development/wifi-nm.md):热点默认密码、`network.env`、`/debug/system`、Web API、sudoers(`ogscope-wifi` / `ogscope-nmcli`)等。 +- **开机引导**:`ogscope-network-boot.service`(root oneshot,`Before=ogscope.service`)在冷启动时若 STA 长时间无可用 IPv4 则切 **AP**,不依赖 Python 进程。 +- **运行时 STA 回滚**:用户通过 Web/API 切 STA 成功后,应用内按 `wifi_sta_rollback_*` 超时无 IPv4 再切回 AP;与开机引导**分工不同、不冲突**(先 boot 完成再启动 `ogscope`)。 +- 二者均可能最终执行「切 AP」,行为**幂等**;不建议删除运行时回滚,否则仅冷启动有保障,**运行中**切 STA 失败后需手动恢复。 + **重要说明**: - 系统库(如 `libcamera`、`picamera2`)安装在系统环境中,通过 `PYTHONPATH` 环境变量注入到虚拟环境 - `LD_LIBRARY_PATH` 确保系统库的链接库路径正确 @@ -137,7 +138,7 @@ WantedBy=multi-user.target ### 常用命令 ```bash # 连接服务器 -ssh [用户名]@[服务器地址] -p [端口] +ssh [ogstartech]@[192.168.31.16] -p [22] # 部署到服务器 # 使用 git clone 或手动上传 @@ -159,20 +160,51 @@ python -m ogscope.main ## Git 工作流 -目前还没上传到Git - 主分支: `main` (稳定版本) - 开发分支: `dev` (开发版本) - 功能分支: `feature/xxx` - 修复分支: `fix/xxx` -提交信息格式: +### 提交信息格式 +**必须使用中英文双语提交信息**,格式如下: ``` -feat: 添加新功能 -fix: 修复bug -docs: 更新文档 -style: 代码格式调整 -refactor: 重构代码 -test: 添加测试 -chore: 构建/工具变更 +中文描述 / English description + +- 中文详细说明 / English detailed explanation +- 中文变更内容 / English change content ``` +示例: +``` +修复相机接口500错误 / Fix camera API 500 error + +- 添加缺失的方法实现 / Add missing method implementations +- 重新组织服务类结构 / Reorganize service class structure +``` + +### 提交类型前缀 +``` +feat: 添加新功能 / Add new feature +fix: 修复bug / Fix bug +docs: 更新文档 / Update documentation +style: 代码格式调整 / Code style changes +refactor: 重构代码 / Refactor code +test: 添加测试 / Add tests +chore: 构建/工具变更 / Build/tool changes +``` + +## 开发板调试配置 + +### 开发板连接信息 +- **IP地址**: 192.168.31.16 +- **用户名**: ogstartech +- **端口**: 22 +- **连接方式**: SSH + +### 调试工作流程 +1. **代码修改后必须上传**: 修改或新建的代码必须先上传到开发板 +2. **不要遗漏文件**: 确保所有相关文件都已上传 +3. **服务重启**: 项目以系统服务方式运行,修改后需要重启服务 +4. **调试前确认**: 如果有不明白的地方,先询问用户再开始工作 + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index afaf6b3..ea95b08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ 如果你发现了 bug,请: -1. 在 [Issues](https://github.com/your-username/OGScope/issues) 页面搜索是否已有相关问题 +1. 在 [Issues](https://github.com/OG-star-tech/OGScope/issues) 页面搜索是否已有相关问题 2. 如果没有,创建新 Issue,包含: - 详细的问题描述 - 复现步骤 @@ -27,7 +27,7 @@ 1. **Fork 项目** ```bash # 在 GitHub 上点击 Fork 按钮 - git clone https://github.com/your-username/OGScope.git + git clone https://github.com/OG-star-tech/OGScope.git cd OGScope ``` @@ -129,7 +129,7 @@ 如果你在贡献过程中遇到问题: - 查看 [开发文档](docs/development/README.md) -- 在 [Discussions](https://github.com/your-username/OGScope/discussions) 提问 +- 在 [Discussions](https://github.com/OG-star-tech/OGScope/discussions) 提问 - 联系维护者 感谢你的贡献!🎉 diff --git a/Makefile b/Makefile index 7b5509a..522dd3c 100644 --- a/Makefile +++ b/Makefile @@ -54,8 +54,8 @@ lock: ## 锁定依赖版本 build: ## 构建包 poetry build -deploy: ## 部署到 Orange Pi(需要配置 SSH) - @echo "同步代码到 Orange Pi..." +deploy: ## 部署到 Raspberry Pi(需要配置 SSH 主机别名) + @echo "同步代码到 Raspberry Pi..." rsync -avz --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ --exclude '.venv' --exclude 'PiFinder-release' \ . orangepi:~/OGScope/ @@ -69,7 +69,7 @@ logs: ## 查看日志 status: ## 查看服务状态 ssh orangepi "sudo systemctl status ogscope" -ssh: ## SSH 到 Orange Pi +ssh: ## SSH 到 Raspberry Pi(主机名见 Makefile 中 rsync 目标) ssh orangepi docs: ## 生成文档(如果使用 Sphinx) diff --git a/README.md b/README.md index 87bf3f4..3f4f0ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # OGScope - 电子极轴镜 -基于 Raspberry Pi Zero 2W 的智能电子极轴镜系统 +基于 Raspberry Pi Zero 2W 的智能电子极轴镜系统,用于天文摄影中的精确极轴校准。 + +[English](README_EN.md) | 中文 ## 硬件平台 @@ -29,6 +31,20 @@ - ⏳ 赤道仪控制 - ⏳ 多设备联动 +### 主要特性 + +- 🔭 **精确校准**: 高精度极轴校准算法 +- 📱 **远程控制**: Web 界面和移动 App +- 🖥️ **本地显示**: 2.4寸 SPI LCD 实时显示 +- 🌐 **生态集成**: 支持 INDI 协议 + +### 技术规格 + +- **处理器**: Raspberry Pi Zero 2W (ARM Cortex-A53) +- **相机**: IMX327 传感器 (1920x1080) +- **显示**: 2.4寸 SPI LCD (240x320) +- **软件**: Python 3.9 + FastAPI + ## 快速开始 ### 环境要求 @@ -41,7 +57,7 @@ ```bash # 克隆项目 -git clone https://github.com/your-username/OGScope.git +git clone https://github.com/OG-star-tech/OGScope.git cd OGScope # 安装依赖(使用 Poetry) @@ -54,20 +70,43 @@ poetry shell python -m ogscope.main ``` +开发板一键部署、WiFi 热点与 **`/debug/system`** 等以 [docs/development/wifi-nm.md](docs/development/wifi-nm.md) 与 [docs/development/README.md](docs/development/README.md) 为准。 + ### Web 界面访问 启动后访问: http://raspberrypi.local:8000 或 http://:8000 +## 文档 + +### 用户文档 +- [快速开始](docs/QUICK_START.md) +- [用户手册](docs/user_guide/user-manual.md) +- [常见问题](docs/user_guide/faq.md) + +### 硬件文档 +- [硬件清单 (BOM)](docs/hardware/bom.md) +- [组装指南](docs/hardware/assembly-guide.md) +- [硬件调试](docs/hardware/hardware-debug.md) + +### 开发文档 +- [开发指南(中文)](docs/development/README.md) +- [开发指南(English)](docs/development/README_EN.md) +- [树莓派 WiFi / AP / STA / 调试页(NetworkManager)](docs/development/wifi-nm.md) +- [FastAPI 开发](docs/development/fastapi-guide.md) +- [测试指南](docs/development/testing-guide.md) + ## 开发 -详见 [开发文档](docs/development/README.md) +详见 [开发文档(中文)](docs/development/README.md) / +[Development Guide (English)](docs/development/README_EN.md) + +### 远程开发配置 -### 远程开发配置 (PyCharm Pro) +当前推荐流程详见 [开发指南](docs/development/README.md): -1. 配置 SSH 连接到 Raspberry Pi -2. 设置远程 Python 解释器 -3. 配置自动部署和同步 -4. 详细步骤见 [PyCharm 远程开发指南](docs/development/pycharm-remote.md) +1. 在本地 IDE 编写代码 +2. 手动上传代码到开发板 +3. 使用 `systemd` 重启并验证服务 ## 项目结构 @@ -86,19 +125,24 @@ OGScope/ └── web/ # Web 前端资源 ``` -## 参考项目 - -- [PiFinder](https://github.com/brickbots/PiFinder) - 板块求解寻星器 -- [OpenMV Polar Scope](https://frank26080115.github.io/OpenMV-Astrophotography-Gear/doc/Polar-Scope.html) - 极轴镜参考 ## 许可证 -MIT License - 详见 [LICENSE](LICENSE) 文件 +本项目采用 [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) 许可证 -## 贡献 +- **署名 (BY)**: 必须标明原作者 +- **非商业性使用 (NC)**: 禁止商业用途 +- **相同方式共享 (SA)**: 衍生作品必须使用相同许可证 -欢迎提交 Issue 和 Pull Request! +详见 [LICENSE](LICENSE) 文件 + +## 快速链接 + +- [GitHub 仓库](https://github.com/OG-star-tech/OGScope) +- [问题反馈](https://github.com/OG-star-tech/OGScope/issues) +- [讨论区](https://github.com/OG-star-tech/OGScope/discussions) + +## 贡献 -## 致谢 +欢迎提交 Issue 和 Pull Request!详见 [贡献指南](CONTRIBUTING.md) -感谢 PiFinder 项目的开源贡献,为本项目提供了宝贵的参考。 diff --git a/README_EN.md b/README_EN.md new file mode 100644 index 0000000..43d5d20 --- /dev/null +++ b/README_EN.md @@ -0,0 +1,148 @@ +# OGScope - Electronic Polar Scope + +An intelligent electronic polar scope system based on Raspberry Pi Zero 2W for precise polar alignment in astrophotography. + +English | [中文](README.md) + +## Hardware Platform + +- **Main Controller**: Raspberry Pi Zero 2W +- **Operating System**: Raspberry Pi OS +- **Camera**: IMX327 MIPI sensor +- **Display**: 2.4" SPI LCD +- **Communication**: WiFi wireless control + +## Features + +### Phase 1 - Basic Features (MVP) +- ✅ Real-time video preview +- ✅ Web remote control +- ✅ Basic polar alignment +- ✅ Camera parameter adjustment + +### Phase 2 - Complete Features +- ⏳ SPI screen display +- ⏳ Automatic plate solving +- ⏳ Mobile app control +- ⏳ Calibration data management + +### Phase 3 - Ecosystem Integration +- ⏳ INDI driver support +- ⏳ Mount control +- ⏳ Multi-device coordination + +### Key Features + +- 🔭 **Precise Alignment**: High-precision polar alignment algorithms +- 📱 **Remote Control**: Web interface and mobile app +- 🖥️ **Local Display**: 2.4" SPI LCD real-time display +- 🌐 **Ecosystem Integration**: INDI protocol support + +### Technical Specifications + +- **Processor**: Raspberry Pi Zero 2W (ARM Cortex-A53) +- **Camera**: IMX327 sensor (1920x1080) +- **Display**: 2.4" SPI LCD (240x320) +- **Software**: Python 3.9 + FastAPI + +## Quick Start + +### Requirements + +- Python 3.9+ +- Poetry 1.2+ +- Raspberry Pi Zero 2W (Raspberry Pi OS) + +### Installation + +```bash +# Clone the project +git clone https://github.com/OG-star-tech/OGScope.git +cd OGScope + +# Install dependencies (using Poetry) +poetry install + +# Activate virtual environment +poetry shell + +# Run the application +python -m ogscope.main +``` + +On the Raspberry Pi, one-shot deployment, WiFi hotspot, and **`/debug/system`** are documented in [docs/development/wifi-nm.md](docs/development/wifi-nm.md) and [docs/development/README_EN.md](docs/development/README_EN.md). + +### Web Interface Access + +After startup, visit: http://raspberrypi.local:8000 or http://:8000 + +## Documentation + +### User Documentation +- [Quick Start](docs/QUICK_START_EN.md) +- [User Manual](docs/user_guide/user-manual.md) +- [FAQ](docs/user_guide/faq.md) + +### Hardware Documentation +- [Bill of Materials (BOM)](docs/hardware/bom.md) +- [Assembly Guide](docs/hardware/assembly-guide.md) +- [Hardware Debugging](docs/hardware/hardware-debug.md) + +### Development Documentation +- [Development Guide (English)](docs/development/README_EN.md) +- [Development Guide (中文)](docs/development/README.md) +- [Raspberry Pi WiFi / AP / STA (NetworkManager)](docs/development/wifi-nm.md) +- [FastAPI Development](docs/development/fastapi-guide.md) +- [Testing Guide](docs/development/testing-guide.md) + +## Development + +See [Development Guide (English)](docs/development/README_EN.md) for details. + +### Remote Development Configuration + +The current recommended workflow is documented in the +[Development Guide (English)](docs/development/README_EN.md): + +1. Write code in local IDE +2. Upload code manually to the dev board +3. Restart and verify service via `systemd` + +## Project Structure + +``` +OGScope/ +├── ogscope/ # Main application package +│ ├── core/ # Core functionality modules +│ ├── hardware/ # Hardware interface layer +│ ├── web/ # FastAPI web service +│ ├── ui/ # SPI screen interface +│ ├── algorithms/ # Astronomical algorithms +│ └── utils/ # Utility functions +├── tests/ # Test code +├── docs/ # Documentation +├── scripts/ # Deployment scripts +└── web/ # Web frontend resources +``` + + +## License + +This project is licensed under [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) + +- **Attribution (BY)**: Must credit the original author +- **NonCommercial (NC)**: Commercial use is prohibited +- **ShareAlike (SA)**: Derivative works must use the same license + +See [LICENSE](LICENSE) file for details. + +## Quick Links + +- [GitHub Repository](https://github.com/OG-star-tech/OGScope) +- [Issue Tracker](https://github.com/OG-star-tech/OGScope/issues) +- [Discussions](https://github.com/OG-star-tech/OGScope/discussions) + +## Contributing + +Issues and Pull Requests are welcome! See [Contributing Guide](CONTRIBUTING.md) for details. + diff --git a/TEST_LOCAL.md b/TEST_LOCAL.md deleted file mode 100644 index de82067..0000000 --- a/TEST_LOCAL.md +++ /dev/null @@ -1,292 +0,0 @@ -# 本地测试指南 - -在没有 Orange Pi 硬件的情况下,也可以在 Mac 上进行开发和测试。 - -## 🧪 本地测试步骤 - -### 1. 安装依赖 - -```bash -cd "/Users/luyifei/Desktop/ogs proj/OGScope " - -# 使用 Poetry 安装依赖 -poetry install - -# 激活虚拟环境 -poetry shell -``` - -### 2. 运行单元测试 - -```bash -# 运行所有测试 -poetry run pytest -v - -# 只运行单元测试 -poetry run pytest -m unit -v - -# 生成覆盖率报告 -poetry run pytest --cov=ogscope --cov-report=html -open htmlcov/index.html # 查看覆盖率报告 -``` - -### 3. 运行 Web 服务 - -```bash -# 方法 1: 使用主程序 -poetry run python -m ogscope.main - -# 方法 2: 直接使用 uvicorn -poetry run uvicorn ogscope.web.app:app --reload --host 127.0.0.1 --port 8000 -``` - -然后在浏览器中访问: -- 主页: http://127.0.0.1:8000 -- API 文档: http://127.0.0.1:8000/docs -- ReDoc: http://127.0.0.1:8000/redoc - -### 4. 代码质量检查 - -```bash -# 代码格式化 -poetry run black ogscope tests - -# 代码检查 -poetry run ruff check ogscope tests - -# 类型检查 -poetry run mypy ogscope - -# 或使用 Makefile -make format -make lint -make check # 运行所有检查 -``` - -### 5. 测试 API - -使用 `httpie` 或 `curl` 测试 API: - -```bash -# 安装 httpie -pip install httpie - -# 测试健康检查 -http GET http://127.0.0.1:8000/health - -# 测试相机状态 -http GET http://127.0.0.1:8000/api/camera/status - -# 测试相机设置 -http POST http://127.0.0.1:8000/api/camera/settings \ - exposure:=10000 \ - gain:=1.5 -``` - -## 🐛 模拟硬件 - -由于没有实际硬件,需要实现模拟驱动。 - -### 创建模拟相机 - -编辑 `ogscope/hardware/camera_debug.py`: - -```python -"""模拟相机驱动(用于开发测试)""" -import numpy as np -from PIL import Image -import time - -class DebugCamera: - """模拟相机,返回测试图像""" - - def __init__(self, width=1920, height=1080): - self.width = width - self.height = height - self.is_streaming = False - - def start(self): - """启动相机""" - self.is_streaming = True - - def stop(self): - """停止相机""" - self.is_streaming = False - - def capture(self): - """捕获一帧图像""" - # 生成测试图像:黑色背景 + 随机星点 - img = np.zeros((self.height, self.width, 3), dtype=np.uint8) - - # 添加随机星点 - num_stars = 100 - for _ in range(num_stars): - x = np.random.randint(0, self.width) - y = np.random.randint(0, self.height) - brightness = np.random.randint(128, 255) - img[y, x] = [brightness, brightness, brightness] - - return img -``` - -然后在 `ogscope/config.py` 中设置: - -```python -camera_type: str = Field(default="debug", description="相机类型") -``` - -### 创建模拟显示屏 - -编辑 `ogscope/hardware/display_debug.py`: - -```python -"""模拟 SPI 显示屏""" -from PIL import Image - -class DebugDisplay: - """将显示内容保存为图像文件""" - - def __init__(self, width=240, height=320): - self.width = width - self.height = height - - def show(self, image): - """显示图像""" - # 保存到文件而不是显示到屏幕 - image.save("debug_display.png") - print(f"Display updated: debug_display.png") -``` - -## 🎨 开发工作流 - -### 推荐工作流程 - -1. **功能开发** - ```bash - # 创建功能分支 - git checkout -b feature/camera-module - - # 开发功能 - # 编辑代码... - - # 运行测试 - make test - - # 提交 - git add . - git commit -m "feat: add camera module" - ``` - -2. **本地测试** - ```bash - # 运行 Web 服务 - make run - - # 在浏览器中测试 - open http://127.0.0.1:8000 - ``` - -3. **代码质量** - ```bash - # 运行所有检查 - make check - ``` - -4. **推送到 GitHub** - ```bash - git push origin feature/camera-module - # 然后创建 Pull Request - ``` - -## 📊 开发进度追踪 - -使用 GitHub Projects 或简单的 TODO.md 文件: - -```markdown -## Phase 1 - MVP - -### 相机模块 -- [x] 创建相机抽象层 -- [x] 实现调试相机 -- [ ] 实现 IMX327 驱动 -- [ ] 添加单元测试 - -### Web 服务 -- [x] 搭建 FastAPI 框架 -- [x] 创建基础 API -- [ ] 实现实时视频流 -- [ ] 添加 WebSocket 支持 - -### 极轴校准 -- [ ] 星点检测算法 -- [ ] 北极星识别 -- [ ] 漂移测试 -- [ ] 误差计算 -``` - -## 🔍 调试技巧 - -### 使用 IPython 调试 - -在代码中插入断点: - -```python -import IPython; IPython.embed() -``` - -### 使用 Loguru 日志 - -```python -from loguru import logger - -logger.debug("调试信息") -logger.info("普通信息") -logger.warning("警告信息") -logger.error("错误信息") -``` - -### PyCharm 调试 - -1. 设置断点(点击行号) -2. 运行调试配置(Bug 图标) -3. 查看变量、调用栈等 - -## ⚡ 快速命令 - -```bash -# 开发模式运行(自动重载) -make dev - -# 运行测试 -make test - -# 代码检查 -make check - -# 清理缓存 -make clean - -# 查看所有命令 -make help -``` - -## 🎯 本地测试目标 - -- ✅ 能够启动 Web 服务 -- ✅ API 端点返回正确响应 -- ✅ 单元测试全部通过 -- ✅ 代码风格检查通过 -- ✅ Web 界面可以访问 -- ✅ 模拟相机可以工作 - -达成以上目标后,就可以部署到 Orange Pi 进行实际硬件测试了! - -## 📝 注意事项 - -1. **不要提交敏感信息**: 确保 `.env` 和 `config.json` 在 `.gitignore` 中 -2. **保持依赖最新**: 定期运行 `poetry update` -3. **编写测试**: 新功能要有对应的单元测试 -4. **文档同步**: 代码变更后更新相关文档 - -Happy coding! 🚀 - diff --git a/assets/icons/battery_charge_icon.svg b/assets/icons/battery_charge_icon.svg new file mode 100644 index 0000000..4b32af2 --- /dev/null +++ b/assets/icons/battery_charge_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/satellite_space_icon.svg b/assets/icons/satellite_space_icon.svg new file mode 100644 index 0000000..d3781a6 --- /dev/null +++ b/assets/icons/satellite_space_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/wifi_icon.svg b/assets/icons/wifi_icon.svg new file mode 100644 index 0000000..5895f6d --- /dev/null +++ b/assets/icons/wifi_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/analysis/presets/official/default_widefield.json b/data/analysis/presets/official/default_widefield.json new file mode 100644 index 0000000..e722d97 --- /dev/null +++ b/data/analysis/presets/official/default_widefield.json @@ -0,0 +1,13 @@ +{ + "id": "default_widefield", + "name": "默认广角 / Default widefield", + "scope": "official", + "params": { + "fov_estimate": 16.0, + "fov_max_error": 5.0, + "solve_timeout_ms": 8000, + "hint_ra_deg": 45.0, + "hint_dec_deg": 80.0 + }, + "created_at": "2026-01-01T00:00:00+00:00" +} diff --git a/data/plate_solve/README.md b/data/plate_solve/README.md new file mode 100644 index 0000000..6eeec1b --- /dev/null +++ b/data/plate_solve/README.md @@ -0,0 +1,5 @@ +# Plate solve 数据目录 / Plate solve data directory + +请将 `default_database.npz`(Tetra3 图案库)放在此目录,或通过 `OGSCOPE_SOLVER_TETRA_DATABASE_PATH` 指定绝对路径。 + +详细说明见 [docs/development/plate-solve-data.md](../../docs/development/plate-solve-data.md)。 diff --git a/default_config.json b/default_config.json deleted file mode 100644 index 4aac1b4..0000000 --- a/default_config.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "version": "0.1.0", - "last_updated": "2025-01-01", - - "camera": { - "type": "imx327_mipi", - "interface": "mipi_csi", - "width": 640, - "height": 360, - "fps": 5, - "exposure_us": 10000, - "analogue_gain": 1.0, - "digital_gain": 1.0, - "flip_horizontal": false, - "flip_vertical": false, - "rotation": 180, - "auto_exposure": false, - "auto_gain": false - }, - - "display": { - "enabled": false, - "type": "st7789", - "width": 240, - "height": 320, - "rotation": 0, - "brightness": 128, - "spi_bus": 0, - "spi_device": 0, - "dc_pin": 25, - "rst_pin": 27 - }, - - "web": { - "host": "0.0.0.0", - "port": 8000, - "cors_origins": ["*"], - "stream_quality": 60, - "stream_fps": 5 - }, - - "polar_alignment": { - "timeout_seconds": 300, - "target_precision_arcmin": 1.0, - "drift_test_duration": 60, - "auto_solve": false - }, - - "location": { - "latitude": 0.0, - "longitude": 0.0, - "elevation_m": 0, - "timezone": "UTC" - }, - - "logging": { - "level": "INFO", - "file": "./logs/ogscope.log", - "max_size_mb": 10, - "backup_count": 5 - }, - - "gpio": { - "display": { - "enabled": false, - "type": "st7789", - "dc_pin": 25, - "rst_pin": 27, - "cs_pin": 8, - "backlight_pin": 18, - "width": 240, - "height": 320, - "rotation": 0 - }, - "buttons": { - "enabled": false, - "button1_pin": 4, - "button2_pin": 5, - "button3_pin": 6, - "button4_pin": 12, - "pull_up": true, - "debounce_ms": 50 - }, - "leds": { - "enabled": true, - "status_led_pin": 16, - "activity_led_pin": 20, - "error_led_pin": 21 - } - } -} - diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md index 80fd261..cc58466 100644 --- a/docs/QUICK_START.md +++ b/docs/QUICK_START.md @@ -2,6 +2,8 @@ 本指南将帮助你快速搭建 OGScope 开发环境。 +English | [中文](QUICK_START.md) + ## 🎯 目标 - ✅ 在 Raspberry Pi Zero 2W 上运行 OGScope @@ -138,7 +140,7 @@ poetry run python -m ogscope.main # 生成 SSH 密钥(如果还没有) ssh-keygen -t ed25519 -C "ogscope-dev" -# 复制公钥到 Orange Pi +# 复制公钥到 Raspberry Pi ssh-copy-id orangepi@orangepi.local # 配置 SSH config @@ -157,7 +159,7 @@ ssh orangepi ### 3.2 PyCharm 配置 -详细步骤见 [PyCharm 远程开发配置](./development/pycharm-remote.md) +建议以 [开发指南](./development/README.md) 中的“远程开发(手动部署 + systemd)”流程为准。 **快速版本**: @@ -210,7 +212,7 @@ http://orangepi.local:8000/redoc # ReDoc ### 检查清单 -- [ ] Orange Pi 可以正常启动 +- [ ] Raspberry Pi 可以正常启动 - [ ] SSH 可以连接 - [ ] Poetry 已安装 - [ ] OGScope 依赖已安装 @@ -221,14 +223,14 @@ http://orangepi.local:8000/redoc # ReDoc ### 运行测试 ```bash -# 在 Orange Pi 上 +# 在 Raspberry Pi 上 cd ~/OGScope poetry run pytest tests/unit/ ``` ## 🐛 故障排除 -### 问题 1: 找不到 Orange Pi +### 问题 1: 找不到 Raspberry Pi ```bash # 方法 1: 使用 IP 地址 diff --git a/docs/QUICK_START_EN.md b/docs/QUICK_START_EN.md new file mode 100644 index 0000000..451f27c --- /dev/null +++ b/docs/QUICK_START_EN.md @@ -0,0 +1,158 @@ +# 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 + +## 📋 Prerequisites + +### Hardware Requirements + +- Raspberry Pi Zero 2W development board +- IMX327 camera module +- 2.4" SPI LCD display +- MicroSD card (32GB+) +- Power supply (5V/2A) + +### Software Requirements + +- macOS/Windows/Linux development machine +- PyCharm Professional 2025 +- Python 3.9+ +- Poetry package manager + +## 🚀 Installation Steps + +### Step 1: Prepare Raspberry Pi Zero 2W + +1. **Flash the OS** + ```bash + # Download Raspberry Pi OS image for Raspberry Pi Zero 2W + # Flash to microSD card using balenaEtcher + ``` + +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 + ``` + +3. **Install Poetry** + ```bash + curl -sSL https://install.python-poetry.org | python3 - + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc + source ~/.bashrc + ``` + +### Step 2: Clone and Setup Project + +```bash +# Clone the repository +git clone https://github.com/OG-star-tech/OGScope.git +cd OGScope + +# Install dependencies +poetry install + +# Activate virtual environment +poetry shell +``` + +### Step 3: Configure PyCharm + +1. **Open Project** + - Launch PyCharm Professional + - Open the OGScope project directory + +2. **Configure File Sync** + - Go to `Tools` → `Deployment` → `Configuration` + - Add SFTP server for Raspberry Pi Zero 2W + - Configure automatic file synchronization + +3. **Setup Run Configurations** + - Create local run configuration for development + - Create remote run configuration for hardware testing + +### Step 4: Run the Application + +```bash +# Local development +python -m ogscope.main + +# Remote testing (on Raspberry Pi) +ssh orangepi +cd /home/pi/OGScope +poetry run python -m ogscope.main +``` + +## 🌐 Access Web Interface + +After starting the application, access: +- Local: http://localhost:8000 +- Remote: http://orangepi.local:8000 + +## 🔧 Development Workflow + +1. **Local Development** + - Write code in PyCharm + - Test basic functionality locally + - Use local run configuration + +2. **File Synchronization** + - Files automatically sync to Raspberry Pi + - Manual sync when needed + +3. **Hardware Testing** + - Switch to remote run configuration + - Test camera and hardware features + - Debug on actual hardware + +## 📚 Next Steps + +- Read [Development Guide](development/README.md) +- Follow the remote workflow in [Development Guide](development/README.md) +- Explore [API Documentation](API_ARCHITECTURE.md) + +## 🆘 Troubleshooting + +### Common Issues + +1. **Connection Problems** + ```bash + # Check network connectivity + ping orangepi.local + + # Verify SSH connection + ssh orangepi + ``` + +2. **Permission Issues** + ```bash + # Fix camera permissions + sudo usermod -a -G video pi + sudo reboot + ``` + +3. **Dependency Issues** + ```bash + # Reinstall dependencies + poetry install --sync + ``` + +## 📞 Support + +- [GitHub Issues](https://github.com/OG-star-tech/OGScope/issues) +- [Discussions](https://github.com/OG-star-tech/OGScope/discussions) +- [Documentation](README.md) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 7a4d9da..0000000 --- a/docs/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# OGScope 文档 - -欢迎来到 OGScope 项目文档! - -## 目录 - -### 用户文档 -- [快速开始](./user_guide/quick-start.md) -- [用户手册](./user_guide/user-manual.md) -- [常见问题](./user_guide/faq.md) - -### 硬件文档 -- [硬件清单 (BOM)](./hardware/bom.md) -- [组装指南](./hardware/assembly-guide.md) -- [硬件调试](./hardware/hardware-debug.md) - -### 开发文档 -- [开发指南](./development/README.md) -- [PyCharm 远程开发](./development/pycharm-remote.md) -- [FastAPI 开发](./development/fastapi-guide.md) -- [测试指南](./development/testing-guide.md) - -## 项目概述 - -OGScope 是一个基于 Orange Pi Zero 2W 的电子极轴镜系统,专为天文摄影爱好者设计。 - -### 主要特性 - -- 🔭 **精确校准**: 高精度极轴校准算法 -- 📱 **远程控制**: Web 界面和移动 App -- 🖥️ **本地显示**: 2.4寸 SPI LCD 实时显示 -- 🌐 **生态集成**: 支持 INDI 协议 - -### 技术规格 - -- **处理器**: Orange Pi Zero 2W (Allwinner H618) -- **相机**: IMX327 传感器 (1920x1080) -- **显示**: 2.4寸 SPI LCD (240x320) -- **软件**: Python 3.9 + FastAPI - -## 快速链接 - -- [GitHub 仓库](https://github.com/your-username/OGScope) -- [问题反馈](https://github.com/your-username/OGScope/issues) -- [讨论区](https://github.com/your-username/OGScope/discussions) - -## 贡献 - -欢迎贡献代码、文档或提出建议!详见 [贡献指南](../CONTRIBUTING.md) - -## 许可证 - -本项目采用 MIT 许可证。详见 [LICENSE](../LICENSE) - diff --git a/docs/SUPERSAMPLE_VERIFICATION.md b/docs/SUPERSAMPLE_VERIFICATION.md deleted file mode 100644 index dc496d0..0000000 --- a/docs/SUPERSAMPLE_VERIFICATION.md +++ /dev/null @@ -1,147 +0,0 @@ -# 超采样功能验证指南 - -本文档介绍如何验证 OGScope 的超采样设置是否有效,确保后续开发中获取的视频流是经过超采样的高质量视频流。 - -## 超采样功能概述 - -超采样(Supersample)是一种图像质量提升技术,通过以高于输出分辨率的分辨率捕获图像,然后使用软件降采样算法(如 INTER_AREA)将图像缩放到目标分辨率。这种方法可以: - -1. **提高图像质量**:减少锯齿和噪声 -2. **增强细节表现**:保留更多图像细节 -3. **改善色彩过渡**:使色彩过渡更加平滑 - -## 验证方法 - -### 1. API 端点验证 - -我们提供了两个专门的 API 端点来验证超采样设置: - -#### 验证超采样设置 -```bash -GET /debug/camera/verify-supersample -``` - -此端点返回详细的超采样配置信息,包括: -- 采样模式状态 -- 捕获分辨率和输出分辨率 -- 超采样比例 -- 质量评估 -- 优化建议 - -#### 测试图像尺寸 -```bash -POST /debug/camera/test-image-size -``` - -此端点捕获一张实际图像并验证: -- 实际图像尺寸是否与预期输出尺寸匹配 -- 超采样功能是否正常工作 - -### 2. 使用测试脚本 - -运行完整的超采样验证测试: - -```bash -# 完整测试(包括直接相机类测试) -python scripts/test_supersample.py - -# 仅 API 测试(适用于远程测试) -python scripts/test_supersample.py --api-only [服务器地址] -``` - -测试脚本会: -1. 验证相机类的超采样配置 -2. 测试 API 端点功能 -3. 在不同分辨率下测试超采样效果 -4. 生成详细的测试报告 - -### 3. 日志监控 - -相机驱动会在日志中记录详细的超采样信息: - -``` -[INFO] 采样模式从 native 切换到: supersample -[INFO] 超采样模式激活: -[INFO] - 捕获分辨率: 1280x720 -[INFO] - 输出分辨率: 640x360 -[INFO] - 超采样比例: 2.00x -[INFO] - 超采样质量: 优秀 -[DEBUG] 超采样降采样: 1280x720 -> 640x360 -``` - -## 验证标准 - -### 超采样比例评估 - -| 比例范围 | 质量等级 | 说明 | -|---------|---------|------| -| ≥ 1.5x | 优秀 | 显著的图像质量提升 | -| ≥ 1.2x | 良好 | 明显的图像质量改善 | -| > 1.0x | 中等 | 有限的图像质量提升 | -| = 1.0x | 无效 | 无超采样效果 | - -### 验证成功的条件 - -1. **采样模式**:设置为 `supersample` -2. **分辨率关系**:捕获分辨率 > 输出分辨率 -3. **超采样比例**:≥ 1.2x(推荐 ≥ 1.5x) -4. **图像尺寸匹配**:实际捕获图像尺寸 = 输出分辨率 -5. **功能正常**:能够正常进行降采样处理 - -## 配置建议 - -### 推荐配置 - -```json -{ - "camera": { - "width": 640, - "height": 360, - "sampling_mode": "supersample" - } -} -``` - -对应的捕获分辨率会自动设置为更高的分辨率(如 1280x720),实现 2.0x 的超采样。 - -### 性能考虑 - -- **高比例超采样**(> 3.0x):可能影响性能,建议适当降低 -- **低比例超采样**(< 1.2x):图像质量提升有限 -- **推荐范围**:1.5x - 2.5x - -## 故障排除 - -### 常见问题 - -1. **超采样未激活** - - 检查 `sampling_mode` 是否设置为 `supersample` - - 验证相机是否正确初始化 - -2. **分辨率不匹配** - - 检查捕获分辨率和输出分辨率的配置 - - 确保捕获分辨率高于输出分辨率 - -3. **图像尺寸错误** - - 检查降采样算法是否正常工作 - - 验证 OpenCV 库是否正确安装 - -### 调试步骤 - -1. 运行验证脚本检查基本配置 -2. 查看日志文件确认超采样状态 -3. 使用 API 端点验证实时状态 -4. 测试不同分辨率下的表现 - -## 开发集成 - -在后续开发中,确保: - -1. **使用正确的相机实例**:通过 `get_camera_instance()` 获取 -2. **检查超采样状态**:调用 `get_camera_info()` 验证配置 -3. **监控日志输出**:关注超采样相关的日志信息 -4. **定期验证**:使用测试脚本定期检查功能状态 - -## 总结 - -通过以上验证方法,你可以确保 OGScope 的超采样功能正常工作,后续开发中获取的视频流将具有更高的图像质量。建议在每次部署后运行验证测试,确保超采样功能按预期工作。 diff --git a/docs/development/README.md b/docs/development/README.md index eb128b4..c51d818 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -1,197 +1,436 @@ -# OGScope 开发文档 +# OGScope 开发指南(开发板部署与调试) -## 目录 +中文 | [English](README_EN.md) -- [PyCharm 远程开发配置](./pycharm-remote.md) -- [FastAPI 开发指南](./fastapi-guide.md) -- [硬件接口开发](./hardware-interface.md) -- [测试指南](./testing-guide.md) +本文档面向项目成员与协作者,说明 OGScope 在 **Raspberry Pi Zero 2W** 等开发板环境中的实际运行方式、依赖要求与标准调试流程。 -## 技术栈 +测试实践请见:[测试指南](testing-guide.md)。 -- **硬件平台**: Orange Pi Zero 2W -- **操作系统**: Debian -- **编程语言**: Python 3.9+ -- **包管理**: Poetry -- **Web 框架**: FastAPI -- **日志系统**: Loguru -- **测试框架**: Pytest +当前推荐流程为:**本地编辑代码 -> 上传到开发板 -> 使用 `systemd` 重启服务验证**。 +该流程与实际硬件运行环境一致,适合涉及相机与系统库依赖的场景。 -## 开发环境设置 +## 0. 部署速查(爱好者复刻) -### 1. 本地开发(Mac) +本节与 **§1–§11** 的关系:**只列最常用命令与检查项**;Poetry/PEP 668、镜像选项、卸载与排错原理见后文对应章节。 + +### 0.1 系统要求 + +- 单板:**ARM**(`aarch64` 或 `armhf`),推荐 **Raspberry Pi Zero 2W** +- 系统:**Debian/apt** 系镜像(与 `picamera2`/`libcamera` 文档一致;脚本会读 `/etc/os-release`,见 **§1.4**) +- Python:**3.10+**(以 `pyproject.toml` 为准) +- 网络:首次安装需拉取依赖;浏览器访问 Web 需可达设备 **TCP 8000**(按需防火墙放行) + +### 0.2 首次安装 ```bash -# 克隆项目 -git clone https://github.com/your-username/OGScope.git -cd OGScope +cd /path/to/OGScope +chmod +x scripts/install.sh +./scripts/install.sh +``` -# 安装 Poetry(如果未安装) -curl -sSL https://install.python-poetry.org | python3 - +说明摘要:默认 `poetry install --only main`;国内网络可 **`export OGSCOPE_MIRROR=cn`**;低配板可 **`OGSCOPE_APT_SLOW=1`**。完整选项见 **§1.4**。安装后:`sudo systemctl start ogscope`。 -# 安装依赖 -poetry install +### 0.3 网络与 WiFi(AP/STA) -# 激活虚拟环境 -poetry shell +- **`install.sh`** 会安装 `network-manager`、`avahi-daemon`,并执行 **`ogscope-network-init.sh init`**(NM 连接、`/etc/ogscope/network.env`、sudoers、主机名与 `hosts` 等),除非 **`OGSCOPE_SKIP_NETWORK_INIT=1`**。 +- 同次安装会写入 **`ogscope-network-boot.service`**(开机无线网络引导:无可用 STA 则回 AP),除非 **`OGSCOPE_SKIP_NETWORK_BOOT=1`**。 +- **日常仅同步代码与依赖**优先 **`./scripts/board-update.sh`**;全量重装或改系统级依赖时再跑 **`install.sh`**(见 **§0.5**)。 +- 热点 SSID/密码、调试页 **`/debug/system`**、API、**开机引导与运行时 STA 回滚** 的分工:**唯一详解**见 **[wifi-nm.md](wifi-nm.md)**。 -# 运行(模拟模式,不需要硬件) -python -m ogscope.main +### 0.4 星图解算数据 + +将 **`default_database.npz`** 放到 **`data/plate_solve/`**(不随仓库分发)。放置与配置见 [plate-solve-data.md](plate-solve-data.md)。 + +### 0.5 日常更新 + +```bash +cd /path/to/OGScope +chmod +x scripts/board-update.sh +# 可选:OGSCOPE_GIT_PULL=1 OGSCOPE_MIRROR=cn +./scripts/board-update.sh ``` -### 2. 远程开发(Orange Pi) +详情见 **§6.2**。 -详见 [PyCharm 远程开发配置](./pycharm-remote.md) +### 0.6 卸载与健康检查 -## 项目结构 +- 卸载服务与 `.venv`:见 **§6.3**(`scripts/uninstall.sh`) +- 健康检查与日志: +```bash +curl -s http://127.0.0.1:8000/health +sudo systemctl status ogscope +sudo journalctl -u ogscope -f ``` -ogscope/ -├── core/ # 核心功能模块 -│ ├── camera.py # 相机抽象层 -│ ├── image_processor.py # 图像处理 -│ ├── plate_solver.py # 板块求解 -│ └── polar_align.py # 极轴校准算法 -├── hardware/ # 硬件接口层 -│ ├── camera_imx327.py # IMX327 驱动 -│ ├── display_spi.py # SPI 屏幕驱动 -│ └── gpio_control.py # GPIO 控制 -├── web/ # Web 服务 -│ ├── app.py # FastAPI 应用 -│ ├── api.py # REST API -│ └── websocket.py # WebSocket -├── ui/ # SPI 屏幕界面 -├── algorithms/ # 算法模块 -├── data/ # 数据管理 -├── indi/ # INDI 集成 -└── utils/ # 工具函数 + +### 0.7 常见故障(简表) + +| 现象 | 处理方向 | +|------|----------| +| `ImportError: picamera2` | 用 `apt` 装相机栈;venv 由 `install.sh` 配置(**§1.2、§3**) | +| PEP 668 / 系统 pip 被拒 | 只用项目 `.venv`,勿在系统 Python 上混装(**§1.2**) | +| 服务无法启动 | 查 `WorkingDirectory`、`ExecStart`、`journalctl`(**§10**) | + +## 1. Python 版本与项目依赖 + +### 1.1 Python 版本基线 + +- 项目版本约束以 `pyproject.toml` 为准:`python = "^3.10"` +- 建议开发板使用 Python 3.10 及以上版本 +- 若其他文档出现 `3.9+`,应视为历史描述 + +### 1.2 Poetry、PEP 668 与虚拟环境(必读) + +- **必须使用 Poetry 创建的项目内虚拟环境**(`.venv`),**禁止**全局设置 `virtualenvs.create false` 后在系统 Python 上混装依赖;否则易触发 **PEP 668**(发行版保护系统 site-packages,`pip`/`poetry` 无法改写系统包)。 +- 开发板推荐由 `scripts/install.sh` 统一写入:`virtualenvs.create true`、`virtualenvs.in-project true`,并尽量启用 **`virtualenvs.options.system-site-packages true`**,使 venv 能解析通过 `apt` 安装的 `picamera2` 等系统包。 +- **生产/板端**默认仅安装运行时依赖:`poetry install --only main`(脚本默认)。若需 pytest、类型检查等,在开发机或板上设置 `OGSCOPE_INSTALL_DEV=1` 后重装。 + +### 1.3 安装 Poetry 与 Python 依赖 + +```bash +# 进入项目目录 +cd /path/to/OGScope + +# 安装 Poetry(若尚未安装) +curl -sSL https://install.python-poetry.org | python3 - +export PATH="$HOME/.local/bin:$PATH" + +# 开发机:完整依赖(含 dev) +poetry install + +# 开发板(手动维护时):仅运行时依赖,与 install.sh 默认一致 +# poetry install --no-interaction --only main ``` -## 开发流程 +### 1.4 使用安装脚本(推荐首次部署) + +仓库提供 `scripts/install.sh`,用于在开发板执行一次性环境准备。脚本会: -### 1. 创建新功能 +- 读取 `/etc/os-release` 识别发行版,**仅支持 Debian/Ubuntu 系**(含 **Raspberry Pi OS**);非该系将退出,避免误改软件源 +- 安装系统依赖与 Poetry +- 配置 Poetry 使用项目 `.venv` 与 `system-site-packages`(Poetry 版本支持时) +- 默认执行 `poetry install --only main`(设 `OGSCOPE_INSTALL_DEV=1` 可装 dev) +- 可选 `OGSCOPE_APT_SLOW=1`:分批 `apt` 并在批次间暂停,减轻低配板内存压力 +- **`OGSCOPE_MIRROR`**:`auto`(默认,按 `LANG`/`LC_*` 与系统时区启发)、`cn`(中国大陆镜像:apt 清华源 + PyPI 清华)、`international`(不替换 apt,PyPI 走默认)。在国内但语言为英文时,请显式 `export OGSCOPE_MIRROR=cn`。 +- 创建 `logs`、`uploads`、`data/plate_solve` 等目录 +- 生成/更新 `systemd` 服务(`ogscope.service`) +- 注入 `PYTHONPATH` 与 `LD_LIBRARY_PATH`(按实际存在的路径) +- 启用服务开机自启 + +执行方式: ```bash -# 创建新分支 -git checkout -b feature/your-feature +cd /path/to/OGScope +chmod +x scripts/install.sh +./scripts/install.sh +``` + +### 1.5 依赖维护建议 -# 开发功能 -# ... +- 保持 `poetry.lock` 与仓库同步 +- 每次上传较大改动后,在板上执行 `./scripts/board-update.sh`,或手动 `poetry install --only main` 后 `sudo systemctl restart ogscope` +- 服务运行时优先使用固定虚拟环境解释器(见第 5 节) -# 运行测试 -poetry run pytest +## 2. 系统环境依赖(重点) -# 代码格式化 -poetry run black ogscope tests -poetry run ruff check ogscope tests +OGScope 除 Python 包依赖外,还依赖开发板系统层的相机生态(如 `picamera2`/`libcamera`)。 -# 提交代码 -git add . -git commit -m "feat: add your feature" -git push origin feature/your-feature +建议系统具备以下基础组件(按发行版实际包名调整): + +- Python 与构建工具:`python3`、`python3-venv`、`python3-dev`、`build-essential` +- 相机相关:`python3-picamera2`(依赖系统 `libcamera` 运行库) +- 图像相关:`libjpeg`、`libpng`、OpenCV 对应系统库 + +示例: + +```bash +sudo apt update +sudo apt install -y \ + python3 python3-venv python3-dev build-essential \ + python3-picamera2 libjpeg-dev libpng-dev libopencv-dev ``` -### 2. 代码规范 +## 3. 为什么虚拟环境里仍要设置 `PYTHONPATH` + +这是本项目在开发板上的关键运行点。 + +- OGScope 通过 Poetry 虚拟环境运行,但相机相关包常通过 `apt` 安装在系统路径(如 `/usr/lib/python3/dist-packages`) +- 这些系统路径默认不一定在 Poetry 虚拟环境的 `sys.path` 中 +- 结果是:服务运行于虚拟环境时,可能找不到 `picamera2` 等系统包 -- 使用 **Black** 进行代码格式化(行长度 88) -- 使用 **Ruff** 进行代码检查 -- 使用 **MyPy** 进行类型检查 -- 遵循 **PEP 8** 规范 -- 编写清晰的文档字符串(Google 风格) +**与 `system-site-packages` 的关系**:启用后,venv 的 `sys.path` 会包含系统 site-packages,一般即可 `import picamera2`;`systemd` 里仍保留 `PYTHONPATH`,用于覆盖不同发行版下 `/usr/local/lib/python3.x/dist-packages` 等路径,二者叠加不冲突。 -### 3. 测试 +因此在服务配置中显式注入 `PYTHONPATH`,将系统 Python 包路径加入解释器搜索路径,例如: + +```ini +Environment=PYTHONPATH=/usr/lib/python3/dist-packages:/usr/local/lib/python3.13/dist-packages +``` + +同时,`libcamera` 的动态链接库也可能不在默认加载路径中,通常需要: + +```ini +Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu +``` + +## 4. 服务启动链路与脚本使用状态 + +### 4.1 当前实际启动链路(在用) + +1. `systemd` 启动服务 `ogscope` +2. `ExecStart` 执行 `python -m ogscope.main`(通常为虚拟环境解释器) +3. `ogscope/main.py` 启动 Uvicorn,加载 `ogscope.web.app:app` + +### 4.2 仓库脚本状态说明(避免误用) + +- `scripts/install.sh` + - 作用:安装依赖并生成 service + - 状态:安装辅助脚本,不是运行时自动调用入口 +- `scripts/board-update.sh` + - 作用:已安装环境下的增量更新(可选 `OGSCOPE_GIT_PULL=1` 执行 `git pull`、`poetry install`、重启 `ogscope`) + - 状态:日常部署推荐入口 +- `scripts/uninstall.sh` + - 作用:停止并移除 `ogscope` systemd 单元、可选删除 `.venv`;默认保留 `logs/`、`data/` 等;需确认(交互输入 `YES` 或 `OGSCOPE_UNINSTALL_CONFIRM=1`) + - 状态:卸载辅助脚本;不卸载系统 apt 包与全局 Poetry +- `scripts/start_debug_console.sh` + - 作用:手动设置 `PYTHONPATH`/`LD_LIBRARY_PATH` 后前台启动 + - 状态:手动调试辅助脚本,不是默认生产启动链路 +- `Makefile` 中 `run/dev/deploy` + - 作用:开发效率命令 + - 状态:辅助入口,不替代 `systemd` 主流程 + +## 5. 配置 `systemd` 服务与开机自启 + +服务文件建议路径: + +- `/etc/systemd/system/ogscope.service` + +参考模板(请替换为实际用户名与路径): + +```ini +[Unit] +Description=OGScope Service +After=network.target + +[Service] +Type=simple +User= +WorkingDirectory= +Environment=PYTHONPATH=/usr/lib/python3/dist-packages:/usr/local/lib/python3.13/dist-packages +Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu +Environment=OGSCOPE_RELOAD=false +Environment=OGSCOPE_LOG_LEVEL=INFO +ExecStart=/bin/python -m ogscope.main +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +启用与启动: ```bash -# 运行所有测试 -poetry run pytest +sudo systemctl daemon-reload +sudo systemctl enable ogscope +sudo systemctl start ogscope +sudo systemctl status ogscope +``` + +## 6. 代码更新与部署流程(团队统一) + +### 6.1 首次部署 -# 运行单元测试 -poetry run pytest -m unit +1. 拉取项目代码 +2. 执行 `scripts/install.sh` +3. 启动并验证 `ogscope` 服务 -# 运行集成测试 -poetry run pytest -m integration +### 6.2 日常代码更新(推荐) -# 生成覆盖率报告 -poetry run pytest --cov=ogscope --cov-report=html +代码更新后(`git pull` 或手动上传)可一键执行(镜像策略与 `install.sh` 相同,通过 `OGSCOPE_MIRROR` 控制): + +```bash +cd /path/to/OGScope +chmod +x scripts/board-update.sh +# 若需先拉取远端代码(仅 git 仓库):OGSCOPE_GIT_PULL=1 ./scripts/board-update.sh +# 中国大陆:OGSCOPE_MIRROR=cn ./scripts/board-update.sh +./scripts/board-update.sh ``` -## 常用命令 +或手动执行: ```bash -# 安装依赖 -poetry install +# 进入项目目录 +cd /path/to/OGScope -# 添加新依赖 -poetry add +# 同步依赖(有 pyproject.toml/poetry.lock 变更时必须执行;板端建议仅 main) +poetry install --no-interaction --only main -# 添加开发依赖 -poetry add --group dev +# 重启服务使新代码生效 +sudo systemctl restart ogscope -# 更新依赖 -poetry update +# 检查状态和日志 +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +``` -# 运行程序 -poetry run python -m ogscope.main +说明: + +- 若仅前端模板/静态文件变更,通常不需要 `poetry install` +- 若服务文件配置有改动,需先 `sudo systemctl daemon-reload` +- 脚本会同步主服务 `ExecStart` 与已安装的 **`ogscope-network-boot.service`** 内 `ExecStart`(项目目录变更时);未安装开机单元则跳过 + +### 6.3 卸载服务与本地环境(`scripts/uninstall.sh`) + +在需要**移除 systemd 服务**、清理项目内 **`.venv`**,或换目录重装时使用 `scripts/uninstall.sh`。脚本**不会**卸载系统已通过 `apt` 安装的包(如 `python3-picamera2`),也**不会**卸载用户级全局 **Poetry**;仅处理 OGScope 服务单元与项目目录内可选内容。 + +**会执行的操作 / What it does** + +- `systemctl stop` / `disable` `ogscope` +- 删除 `/etc/systemd/system/ogscope.service`(若存在) +- 若存在 **`ogscope-network-boot.service`**:`stop` / `disable` 并删除该 unit(与 `install.sh` 安装的引导一致) +- 若存在 **`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`**:删除该 drop-in(空目录会尝试 `rmdir`) +- `daemon-reload` +- 默认删除项目根目录下的 **`.venv`**(可用环境变量保留,见下) + +**默认保留 / Kept by default** + +- `logs/`、`uploads/`、`data/`(含 `data/plate_solve` 等);若需一并删除,须显式开启(见下) -# 运行测试 -poetry run pytest +**环境变量 / Environment** -# 代码格式化 -poetry run black ogscope tests +| 变量 | 含义 | +|------|------| +| `OGSCOPE_UNINSTALL_CONFIRM=1` | **非交互场景必须设置**(如 CI、脚本),否则脚本在非 TTY 下直接退出 | +| `OGSCOPE_UNINSTALL_KEEP_VENV=1` | 保留 `.venv`,不删除虚拟环境 | +| `OGSCOPE_UNINSTALL_REMOVE_DATA=1` | **危险**:删除 `logs/`、`uploads/`、`data/`(含星库等用户数据) | -# 代码检查 -poetry run ruff check ogscope tests +**交互确认 / Interactive**:在终端前台运行时,若未设置 `OGSCOPE_UNINSTALL_CONFIRM=1`,需输入全大写 **`YES`** 才会继续。 -# 类型检查 -poetry run mypy ogscope +```bash +cd /path/to/OGScope +chmod +x scripts/uninstall.sh + +# 交互:按提示输入 YES +./scripts/uninstall.sh + +# 非交互:确认后执行 +OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh + +# 保留虚拟环境,仅移除服务 +OGSCOPE_UNINSTALL_CONFIRM=1 OGSCOPE_UNINSTALL_KEEP_VENV=1 ./scripts/uninstall.sh + +# 同时删除日志与数据目录(慎用) +OGSCOPE_UNINSTALL_CONFIRM=1 OGSCOPE_UNINSTALL_REMOVE_DATA=1 ./scripts/uninstall.sh ``` -## 调试技巧 +卸载后若需再次部署,重新执行 `./scripts/install.sh` 即可。 -### 1. 使用 IPython +## 7. PyCharm 远程开发(当前实践) -```python -# 在代码中插入断点 -import IPython; IPython.embed() +当前采用的是 **“本地 IDE 编辑 + 手动部署到开发板”** 模式,而不是由 IDE 直接接管远程运行。 + +推荐标准流程: + +1. 在 PyCharm 本地完成代码修改 +2. 将变更上传到开发板 +3. 执行 `sudo systemctl restart ogscope` +4. 通过 `status` 与 `journalctl` 检查启动结果 +5. 通过 Web/API 完成功能验证 + +## 8. 调试 SOP(建议团队统一) + +```bash +# 上传代码后重启服务 +sudo systemctl restart ogscope + +# 查看服务状态 +sudo systemctl status ogscope + +# 跟踪运行日志 +sudo journalctl -u ogscope -f ``` -### 2. 使用 Loguru +接口快速验证: -```python -from loguru import logger +```bash +curl http://localhost:8000/health +curl http://localhost:8000/api +``` + +## 9. API 文档与在线调试 + +### 9.1 文档入口 + +服务启动后,FastAPI 自动提供交互式 API 文档: + +| 地址 | 说明 | +|------|------| +| `http://:8000/docs` | Swagger UI — 交互式接口测试 | +| `http://:8000/redoc` | ReDoc — 结构化接口文档 | +| `http://:8000/openapi.json` | OpenAPI Schema (JSON) | + +### 9.2 API 分组(Tags) + +所有接口在文档中按模块分组展示,分组通过路由注册时的 `tags` 参数控制: + +| 分组 | 模块 | 说明 | +|------|------|------| +| Camera - 相机 | `ogscope.web.api.camera` | 相机控制与图像获取 | +| Alignment - 极轴校准 | `ogscope.web.api.alignment` | 极轴校准流程与状态 | +| System - 系统 | `ogscope.web.api.system` | 系统信息与配置管理 | +| Debug - 调试 | `ogscope.web.api.debug` | 调试控制台接口 | -logger.debug("调试信息") -logger.info("普通信息") -logger.warning("警告信息") -logger.error("错误信息") +分组在 `ogscope/web/api/main.py` 中通过 `include_router()` 的 `tags` 参数指定,描述信息在 `ogscope/web/app.py` 的 `openapi_tags` 中定义。 + +### 9.3 新增 API 模块时的文档配置 + +新增一个 API 模块后,需在两处添加配置,以确保文档正确分组: + +1. **`ogscope/web/app.py`** — 在 `openapi_tags` 列表中添加分组描述: + +```python +{ + "name": "NewModule - 新模块", + "description": "模块说明 / Module description", +}, ``` -### 3. PyCharm 远程调试 +2. **`ogscope/web/api/main.py`** — 注册路由时指定 `tags`: -在 PyCharm 中设置断点,然后使用调试模式运行 +```python +router.include_router(new_router, tags=["NewModule - 新模块"]) +``` -## 常见问题 +### 9.4 ReDoc 自定义说明 -### Q: 如何模拟相机进行开发? +项目使用自定义 ReDoc 路由(固定版本 `redoc@2.1.5`),而非 FastAPI 默认的 `redoc@next`,以避免预发布版本不稳定导致页面空白。相关配置见 `ogscope/web/app.py` 中的 `custom_redoc()` 函数。 -A: 在 `ogscope/hardware/camera_debug.py` 中实现模拟相机,返回测试图像 +## 10. 常见故障排查 -### Q: 如何在 Mac 上测试 SPI 屏幕代码? +若服务启动失败,优先检查: -A: 使用模拟 SPI 驱动,将显示内容保存为图像文件 +- `WorkingDirectory` 是否指向项目根目录 +- `ExecStart` 是否使用正确的虚拟环境 Python +- `PYTHONPATH` 是否包含系统 `dist-packages` +- `LD_LIBRARY_PATH` 是否包含 `libcamera` 相关库路径 +- 最近代码上传是否完整,依赖是否已重新安装 +- **`No module named 'scipy'`**:`board-update.sh` / `install.sh` 会在 `poetry install` 后校验并自动 `--no-cache` 重试与 pip 补装;若仍失败,删除 `.venv` 后执行 `OGSCOPE_MIRROR=cn ./scripts/board-update.sh`(或重装 `./scripts/install.sh`) -### Q: 如何贡献代码? +## 11. 常用命令速查 -A: -1. Fork 项目 -2. 创建功能分支 -3. 提交代码 -4. 创建 Pull Request +```bash +# 安装/更新依赖(开发机);板端可用 ./scripts/board-update.sh +poetry install -## 参考资源 +# 前台手动启动(调试时) +poetry run python -m ogscope.main -- [FastAPI 文档](https://fastapi.tiangolo.com/) -- [Poetry 文档](https://python-poetry.org/docs/) -- [Orange Pi 官方文档](http://www.orangepi.org/) -- [PiFinder 项目](https://github.com/brickbots/PiFinder) +# systemd 管理 +sudo systemctl restart ogscope +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +# 卸载服务与 .venv(详见 §6.3;需确认或 OGSCOPE_UNINSTALL_CONFIRM=1) +# ./scripts/uninstall.sh +# OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh +``` diff --git a/docs/development/README_EN.md b/docs/development/README_EN.md new file mode 100644 index 0000000..8c545e9 --- /dev/null +++ b/docs/development/README_EN.md @@ -0,0 +1,437 @@ +# OGScope Development Guide (Board Deployment and Debugging) + +[中文](README.md) | English + +This document explains how OGScope is actually run on development boards +(primarily **Raspberry Pi Zero 2W**), including dependency requirements, service startup, +and the team-standard debug workflow. + +For testing workflow, see [Testing Guide](testing-guide.md). + +Recommended workflow: **edit locally -> upload to board -> restart with +`systemd` -> verify**. +This matches real hardware runtime behavior. + +## 0. Quick deployment checklist (hobbyists) + +This section lists **common commands and checks only**. For Poetry/PEP 668, mirror options, uninstall, and troubleshooting details, see **§1–§11** below. + +### 0.1 Requirements + +- Board: **ARM** (`aarch64` or `armhf`), e.g. Raspberry Pi Zero 2W +- OS: **Debian/apt**-based images (compatible with `picamera2`/`libcamera`; install script reads `/etc/os-release`, see **§1.4**) +- Python: **3.10+** (see `pyproject.toml`) +- Network: first install downloads dependencies; Web UI needs **TCP 8000** reachable (configure firewall as needed) + +### 0.2 First-time install + +```bash +cd /path/to/OGScope +chmod +x scripts/install.sh +./scripts/install.sh +``` + +Summary: default `poetry install --only main`; in mainland China use **`export OGSCOPE_MIRROR=cn`**; low-memory boards: **`OGSCOPE_APT_SLOW=1`**. Full options: **§1.4**. After install: `sudo systemctl start ogscope`. + +### 0.3 Network and WiFi (AP/STA) + +- **`install.sh`** installs `network-manager`, `avahi-daemon`, and runs **`ogscope-network-init.sh init`** (NM profiles, `/etc/ogscope/network.env`, sudoers, hostname/`hosts`, …) unless **`OGSCOPE_SKIP_NETWORK_INIT=1`**. +- The same install writes **`ogscope-network-boot.service`** (boot-time WiFi: fall back to AP if STA has no usable IPv4) unless **`OGSCOPE_SKIP_NETWORK_BOOT=1`**. +- For **routine code and dependency sync**, prefer **`./scripts/board-update.sh`**; rerun **`install.sh`** only for full reinstall or system-level changes (see **§0.5**). +- Hotspot SSID/password, **`/debug/system`**, APIs, and **boot vs runtime STA rollback**: see **[wifi-nm.md](wifi-nm.md)** (authoritative). + +### 0.4 Plate-solve data + +Place **`default_database.npz`** under **`data/plate_solve/`** (not shipped in the repo). See [plate-solve-data.md](plate-solve-data.md). + +### 0.5 Routine updates + +```bash +cd /path/to/OGScope +chmod +x scripts/board-update.sh +# optional: OGSCOPE_GIT_PULL=1 OGSCOPE_MIRROR=cn +./scripts/board-update.sh +``` + +Details: **§6.2**. + +### 0.6 Uninstall and health check + +- Remove service and `.venv`: **§6.3** (`scripts/uninstall.sh`) +- Health and logs: + +```bash +curl -s http://127.0.0.1:8000/health +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +``` + +### 0.7 Troubleshooting (short) + +| Symptom | Where to look | +|---------|----------------| +| `ImportError: picamera2` | Install camera stack with `apt`; venv from `install.sh` (**§1.2, §3**) | +| PEP 668 | Use project `.venv` only; do not mix into system Python (**§1.2**) | +| Service fails to start | `WorkingDirectory`, `ExecStart`, `journalctl` (**§10**) | + +## 1. Python Version and Project Dependencies + +### 1.1 Python baseline + +- Source of truth: `pyproject.toml` (`python = "^3.10"`) +- Recommended board runtime: Python 3.10+ +- Any `3.9+` wording in old docs should be treated as historical + +### 1.2 Poetry, PEP 668, and the virtual environment (required reading) + +- **You must use a Poetry-managed project venv** (`.venv`). Do **not** set + `virtualenvs.create false` globally and mix packages into the system Python; + that leads to **PEP 668** errors (distribution-managed site-packages cannot be + modified by `pip`/`poetry`). +- On the board, run `scripts/install.sh` to set `virtualenvs.create true`, + `virtualenvs.in-project true`, and preferably + **`virtualenvs.options.system-site-packages true`** so the venv can import + `apt`-installed `picamera2`. +- **Production defaults** to runtime-only deps: `poetry install --only main` + (script default). For pytest and dev tools, set `OGSCOPE_INSTALL_DEV=1` on a + dev machine or board and reinstall. + +### 1.3 Install Poetry and Python packages + +```bash +cd /path/to/OGScope +curl -sSL https://install.python-poetry.org | python3 - +export PATH="$HOME/.local/bin:$PATH" +# dev machine: full dependency set including dev +poetry install +# board (manual): match install.sh default +# poetry install --no-interaction --only main +``` + +### 1.4 Install script (recommended for first-time setup) + +The repository provides `scripts/install.sh`. It performs initial board setup: + +- reads `/etc/os-release` and **only supports Debian/Ubuntu family** (including **Raspberry Pi OS**); aborts on other distros for safety +- installs system dependencies and Poetry +- configures Poetry for `.venv` and `system-site-packages` (when supported) +- defaults to `poetry install --only main` (set `OGSCOPE_INSTALL_DEV=1` for dev) +- optional `OGSCOPE_APT_SLOW=1`: stagger `apt` and pause between batches on low-memory boards +- **`OGSCOPE_MIRROR`**: `auto` (default, heuristic from `LANG`/`LC_*` and timezone), `cn` (mainland China mirrors for apt + PyPI via Tsinghua), `international` (do not rewrite apt; default PyPI). If you are in China but use `en_US` locale, set `export OGSCOPE_MIRROR=cn`. +- creates `logs`, `uploads`, `data/plate_solve`, etc. +- creates/updates `systemd` service (`ogscope.service`) +- injects `PYTHONPATH` and `LD_LIBRARY_PATH` (paths that exist) +- enables service autostart + +Run: + +```bash +cd /path/to/OGScope +chmod +x scripts/install.sh +./scripts/install.sh +``` + +### 1.5 Dependency maintenance + +- keep `poetry.lock` in sync with the repository +- after updates on the board, run `./scripts/board-update.sh`, or + `poetry install --only main` then `sudo systemctl restart ogscope` +- prefer a fixed venv Python in service startup (see section 5) + +## 2. System Dependencies (Important) + +OGScope depends on board-level camera stack (`picamera2`/`libcamera`) in +addition to Poetry packages. + +Typical requirements: + +- Python/build tools: `python3`, `python3-venv`, `python3-dev`, `build-essential` +- camera stack: `python3-picamera2` (with system `libcamera` runtime) +- image stack: `libjpeg`, `libpng`, OpenCV-related system libs + +Example: + +```bash +sudo apt update +sudo apt install -y \ + python3 python3-venv python3-dev build-essential \ + python3-picamera2 libjpeg-dev libpng-dev libopencv-dev +``` + +## 3. Why `PYTHONPATH` Is Needed in a Virtual Environment + +This is a key runtime detail for this project. + +- OGScope runs in a Poetry virtual environment +- board camera packages are often installed via `apt` into system paths (for + example `/usr/lib/python3/dist-packages`) +- those system paths are not always in the Poetry venv `sys.path` + +Result: service may fail to import packages such as `picamera2`. + +**Relationship to `system-site-packages`**: when enabled, the venv `sys.path` +includes system site-packages, which is usually enough to `import picamera2`. +`PYTHONPATH` in `systemd` still covers distro-specific paths such as +`/usr/local/lib/python3.x/dist-packages`; both layers work together. + +So the service explicitly injects `PYTHONPATH`, for example: + +```ini +Environment=PYTHONPATH=/usr/lib/python3/dist-packages:/usr/local/lib/python3.13/dist-packages +``` + +And for shared libraries: + +```ini +Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu +``` + +## 4. Startup Chain and Script Status + +### 4.1 Active runtime startup chain + +1. `systemd` starts service `ogscope` +2. `ExecStart` runs `python -m ogscope.main` (typically venv Python) +3. `ogscope/main.py` starts Uvicorn with `ogscope.web.app:app` + +### 4.2 Script status in repository + +- `scripts/install.sh` + - purpose: setup/install and create service + - status: installer utility, not a runtime auto-invoked entrypoint +- `scripts/board-update.sh` + - purpose: incremental update after install (optional `OGSCOPE_GIT_PULL=1` for + `git pull`, `poetry install`, restart `ogscope`) + - status: recommended for routine deployment +- `scripts/uninstall.sh` + - purpose: stop and remove `ogscope` systemd unit, optionally remove `.venv`; + keeps `logs/`, `data/` by default; requires confirmation (`YES` or + `OGSCOPE_UNINSTALL_CONFIRM=1`) + - status: uninstall helper; does not remove apt packages or global Poetry +- `scripts/start_debug_console.sh` + - purpose: foreground run with `PYTHONPATH`/`LD_LIBRARY_PATH` + - status: manual debug helper, not default production startup +- `Makefile` (`run/dev/deploy`) + - purpose: developer convenience commands + - status: helper entrypoints, not replacement for `systemd` + +## 5. Configure `systemd` Service and Autostart + +Recommended service path: + +- `/etc/systemd/system/ogscope.service` + +Template: + +```ini +[Unit] +Description=OGScope Service +After=network.target + +[Service] +Type=simple +User= +WorkingDirectory= +Environment=PYTHONPATH=/usr/lib/python3/dist-packages:/usr/local/lib/python3.13/dist-packages +Environment=LD_LIBRARY_PATH=/usr/lib/aarch64-linux-gnu +Environment=OGSCOPE_RELOAD=false +Environment=OGSCOPE_LOG_LEVEL=INFO +ExecStart=/bin/python -m ogscope.main +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable ogscope +sudo systemctl start ogscope +sudo systemctl status ogscope +``` + +## 6. Code Update and Deployment Flow (Team Standard) + +### 6.1 First deployment + +1. Clone/pull project code +2. Run `scripts/install.sh` +3. Start and validate `ogscope` service + +### 6.2 Daily update flow + +After code updates (`git pull` or manual upload), you can run: + +```bash +cd /path/to/OGScope +chmod +x scripts/board-update.sh +# with git and need pull: OGSCOPE_GIT_PULL=1 ./scripts/board-update.sh +./scripts/board-update.sh +``` + +Or manually: + +```bash +cd /path/to/OGScope +poetry install --no-interaction --only main +sudo systemctl restart ogscope +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +``` + +Notes: + +- if only templates/static files changed, `poetry install` is usually not needed +- if service file changed, run `sudo systemctl daemon-reload` first +- the script syncs **`ExecStart`** for the main `ogscope` unit and, if installed, **`ogscope-network-boot.service`** (when the project directory path changed); if the boot unit was never installed, that step is skipped + +### 6.3 Uninstall service and local environment (`scripts/uninstall.sh`) + +Use `scripts/uninstall.sh` when you need to **remove the systemd unit**, delete the project **`.venv`**, or clean up before reinstalling in another directory. The script **does not** remove packages installed with `apt` (e.g. `python3-picamera2`) or the user-level **Poetry** installation; it removes the OGScope main unit, the optional **network boot** unit and **drop-in** (if present), and optional content under the project tree as described below. + +**What it does** + +- `systemctl stop` / `disable` `ogscope` +- removes `/etc/systemd/system/ogscope.service` if present +- if **`ogscope-network-boot.service`** exists: `stop` / `disable` and remove that unit (matches what `install.sh` installs for boot-time WiFi) +- if **`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`** exists: remove that drop-in (empty `ogscope.service.d` is removed with `rmdir` when possible) +- `daemon-reload` +- by default removes `.venv` at the project root (can be kept; see below) + +**Kept by default** + +- `logs/`, `uploads/`, `data/` (including `data/plate_solve/`); to remove them you must opt in (below) + +**Environment variables** + +| Variable | Meaning | +|----------|---------| +| `OGSCOPE_UNINSTALL_CONFIRM=1` | **Required for non-interactive** runs (CI, scripts); without it, the script exits when stdin is not a TTY | +| `OGSCOPE_UNINSTALL_KEEP_VENV=1` | keep `.venv` | +| `OGSCOPE_UNINSTALL_REMOVE_DATA=1` | **dangerous**: deletes `logs/`, `uploads/`, `data/` (user data including plate DB) | + +**Interactive**: if `OGSCOPE_UNINSTALL_CONFIRM=1` is not set and the session is a TTY, type **`YES`** in full caps to proceed. + +```bash +cd /path/to/OGScope +chmod +x scripts/uninstall.sh + +# Interactive: type YES when prompted +./scripts/uninstall.sh + +# Non-interactive +OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh + +# Remove service only, keep venv +OGSCOPE_UNINSTALL_CONFIRM=1 OGSCOPE_UNINSTALL_KEEP_VENV=1 ./scripts/uninstall.sh + +# Also remove logs and data (use with care) +OGSCOPE_UNINSTALL_CONFIRM=1 OGSCOPE_UNINSTALL_REMOVE_DATA=1 ./scripts/uninstall.sh +``` + +To deploy again after uninstall, run `./scripts/install.sh`. + +## 7. PyCharm Remote Development (Current Practice) + +Current practice is **local IDE editing + manual deployment to board**, not +IDE-managed remote runtime. + +Recommended steps: + +1. edit in local PyCharm +2. upload changes to board +3. run `sudo systemctl restart ogscope` +4. verify via `status` and `journalctl` +5. validate behavior via Web/API + +## 8. Debug SOP + +```bash +sudo systemctl restart ogscope +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +``` + +Quick API checks: + +```bash +curl http://localhost:8000/health +curl http://localhost:8000/api +``` + +## 9. API Documentation and Interactive Debugging + +### 9.1 Documentation Endpoints + +Once the service is running, FastAPI provides interactive API documentation automatically: + +| URL | Description | +|-----|-------------| +| `http://:8000/docs` | Swagger UI — interactive API testing | +| `http://:8000/redoc` | ReDoc — structured API documentation | +| `http://:8000/openapi.json` | OpenAPI Schema (JSON) | + +### 9.2 API Grouping (Tags) + +All endpoints are grouped by module in the documentation. Grouping is controlled via the `tags` parameter during router registration: + +| Group | Module | Description | +|-------|--------|-------------| +| Camera - 相机 | `ogscope.web.api.camera` | Camera control and image capture | +| Alignment - 极轴校准 | `ogscope.web.api.alignment` | Polar alignment workflow and status | +| System - 系统 | `ogscope.web.api.system` | System information and configuration | +| Debug - 调试 | `ogscope.web.api.debug` | Debug console endpoints | + +Tags are specified in `ogscope/web/api/main.py` via the `tags` parameter of `include_router()`. Group descriptions are defined in the `openapi_tags` list in `ogscope/web/app.py`. + +### 9.3 Adding Documentation for New API Modules + +When adding a new API module, update two files to ensure proper documentation grouping: + +1. **`ogscope/web/app.py`** — add a group description to the `openapi_tags` list: + +```python +{ + "name": "NewModule - 新模块", + "description": "Module description / 模块说明", +}, +``` + +2. **`ogscope/web/api/main.py`** — specify `tags` when registering the router: + +```python +router.include_router(new_router, tags=["NewModule - 新模块"]) +``` + +### 9.4 Custom ReDoc Configuration + +The project uses a custom ReDoc route with a pinned version (`redoc@2.1.5`) instead of FastAPI's default `redoc@next`, to avoid blank pages caused by unstable pre-release builds. See the `custom_redoc()` function in `ogscope/web/app.py`. + +## 10. Troubleshooting Checklist + +If service fails to start, check: + +- `WorkingDirectory` points to project root +- `ExecStart` uses correct venv Python +- `PYTHONPATH` includes system `dist-packages` +- `LD_LIBRARY_PATH` includes `libcamera` library path +- code upload is complete and dependencies are installed +- **`No module named 'scipy'`**: `board-update.sh` / `install.sh` verify imports after `poetry install` and retry with `--no-cache` plus a pip fallback; if it still fails, remove `.venv` and run `OGSCOPE_MIRROR=cn ./scripts/board-update.sh` (or `./scripts/install.sh`) + +## 11. Command Cheatsheet + +```bash +# dev machine; on board use ./scripts/board-update.sh +poetry install +poetry run python -m ogscope.main +sudo systemctl restart ogscope +sudo systemctl status ogscope +sudo journalctl -u ogscope -f + +# Uninstall service and .venv (see §6.3; requires confirm or OGSCOPE_UNINSTALL_CONFIRM=1) +# ./scripts/uninstall.sh +# OGSCOPE_UNINSTALL_CONFIRM=1 ./scripts/uninstall.sh +``` diff --git a/docs/development/ogscope-service-hardening.md b/docs/development/ogscope-service-hardening.md new file mode 100644 index 0000000..4aa6eef --- /dev/null +++ b/docs/development/ogscope-service-hardening.md @@ -0,0 +1,36 @@ +# OGScope 服务稳定性与内存防护 / Service stability and memory guard + +## systemd 建议(示例,按板子内存调整)/ systemd suggestions (tune MemoryMax per board) + +在单元文件 `[Service]` 段可考虑: + +- `Restart=always`:进程异常退出后自动拉起 / Auto-restart after exit +- `RestartSec=3`:避免崩溃重启风暴 / Back off between restarts +- `MemoryMax=400M`(示例):限制单服务 RSS,降低拖死整机的概率(Zero2W 请按实际调)/ Cap service RSS to reduce OOM risk + +示例片段 / Example snippet: + +```ini +[Service] +Restart=always +RestartSec=3 +# MemoryMax=400M +``` + +## OOM 观测 / OOM observation + +在设备上可快速确认是否被 OOM killer 终止: + +```bash +sudo journalctl -k -b | grep -i -E 'oom|killed process|Out of memory' +sudo dmesg -T | grep -i -E 'oom|killed process' +``` + +## 与预览相关的环境变量 / Preview-related environment variables + +| 变量 / Variable | 含义 / Meaning | +|-----------------|----------------| +| `OGSCOPE_PREVIEW_JPEG_QUALITY` | 共享预览 JPEG 质量(与调试 MJPEG 默认质量一致)/ Shared preview JPEG quality | +| `OGSCOPE_SHARED_PREVIEW_FPS` | 共享抓帧与 MJPEG 推送目标帧率 / Shared grabber and MJPEG pacing FPS | +| `OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS` | 调试「单帧预览」接口每客户端最小间隔(毫秒);过短返回 304 / Min interval for `/api/debug/camera/preview` per client | +| `OGSCOPE_KEEP_RAW_CACHE` | `1` 时在共享管理器中常驻 `_latest_raw`;默认 `0` 以省内存 / Retain raw frame cache when `1` | diff --git a/docs/development/plate-solve-data.md b/docs/development/plate-solve-data.md new file mode 100644 index 0000000..bd8318d --- /dev/null +++ b/docs/development/plate-solve-data.md @@ -0,0 +1,63 @@ +# 星空解算数据维护方案 / Plate solve data maintenance + +本文说明 OGScope 在移除 SQLite/HYG 星表后,**仅依赖 Tetra3(Cedar-Solve)图案库** `default_database.npz` 的部署、备份与排障。 + +## 1. 数据形态 / What the file is + +- **不是**关系型数据库:无 `stars.db`、无 HYG CSV 索引。 +- **`default_database.npz`** 为 NumPy 压缩归档,内含: + - 预计算的 **四星图案哈希表**(用于 lost-in-space 匹配) + - **星表向量**(球面 KD 树等),与构建时所用的 Hipparcos/Tycho/BSC 等相关 +- 运行时由 `tetra3.Tetra3` 加载到内存;解算结果中的 `tetra` 字段会附带 Tetra 原始输出(含 `status`、`Matches`、`RMSE` 等)。 + +源码中 vendored 包位置:`ogscope/vendor/tetra3/`(Apache-2.0,见 `ogscope/vendor/tetra3/LICENSE.txt`)。 + +## 2. 获取图案库文件 / Obtaining `default_database.npz` + +**自动复制(安装/更新脚本)**:若 `ogscope/vendor/tetra3/data/default_database.npz` 已存在,或 Poetry 解析到的 `tetra3` 包内带有该文件,`scripts/install.sh` 与 `scripts/board-update.sh` 会将其复制到 `data/plate_solve/default_database.npz`(目标已存在则跳过;覆盖用 `OGSCOPE_FORCE_PLATE_DB=1`;跳过整步用 `OGSCOPE_SKIP_PLATE_DB=1`)。 + +**Auto-copy (install/update scripts)**: If `ogscope/vendor/tetra3/data/default_database.npz` exists, or the resolved `tetra3` package ships it, `scripts/install.sh` and `scripts/board-update.sh` copy it to `data/plate_solve/default_database.npz` (skip if dest exists; `OGSCOPE_FORCE_PLATE_DB=1` to overwrite; `OGSCOPE_SKIP_PLATE_DB=1` to skip). + +任选其一: + +1. **从 PyPI `cedar-solve` wheel 提取** + 安装 wheel 后,在 site-packages 中查找 `tetra3/data/default_database.npz`,复制到 `data/plate_solve/`。 +2. **自行生成**(换 FOV、极限星等时) + 使用 `tetra3.Tetra3.generate_database()`,并按上游文档准备 `hip_main` / `tyc_main` / `BSC5` 等星表文件(生成耗时可能很长)。 + +## 3. 配置与部署 / Configuration + +| 方式 | 说明 | +|------|------| +| 默认 | 若存在 `data/plate_solve/default_database.npz`,优先通过配置解析为该路径 | +| `OGSCOPE_PLATE_SOLVE_DIR` | 图案库目录(默认 `./data/plate_solve`) | +| `OGSCOPE_SOLVER_TETRA_DATABASE_PATH` | `default_database.npz` 的**绝对路径**(最高优先级) | + +应用配置项见 `ogscope/config.py`:`plate_solve_dir`、`solver_tetra_database_path`、`solver_fov_deg`、`solver_fov_max_error_deg`、`solver_timeout_ms`。 + +**systemd** 部署时:将 `WorkingDirectory` 设为项目根,并确保 `data/plate_solve/default_database.npz` 存在或环境变量指向设备上可读路径。 + +## 4. 版本与备份 / Versioning and backup + +- 在 `poetry.lock` 或发行说明中**记录**与 `ogscope/vendor/tetra3` 对齐的 Cedar-Solve / Tetra3 版本思路(当前为 vendored 快照)。 +- **备份**:对生产用 `default_database.npz` 保留副本(可按文件大小 + SHA256 校验)。 +- **升级**:替换 `.npz` 后重启服务;建议在板子上用调试页上传测试图验证 `status: MATCH_FOUND`。 + +## 5. 与旧方案关系 / Migration from SQLite catalog + +- 已移除:`/api/catalog`、`HYG` 下载、`stars.db`、调试页星表 CRUD。 +- 解算置信度不再来自「本地星表密度」,而来自 Tetra 的 `Prob`、`Matches`、`RMSE` 等。 + +## 6. 故障排查 / Troubleshooting + +| 现象 | 可能原因 | 处理 | +|------|-----------|------| +| `DATABASE_ERROR` / 无法加载 | `.npz` 缺失或路径错误 | 检查文件与 `OGSCOPE_SOLVER_TETRA_DATABASE_PATH` | +| `TOO_FEW` | 检出星点 < 4 | 曝光/阈值、减少云与前景光 | +| `NO_MATCH` / `TIMEOUT` | FOV 与库不匹配、假星多 | 调整 `solver_fov_deg`、星点提取、`solve_timeout_ms` | +| 窄视场长期失败 | 默认库偏 10°–30° 一类 | 使用 `generate_database` 生成匹配 FOV 的库 | + +## 7. 性能提示 / Performance + +- Raspberry Pi Zero 2W 等资源受限设备:可适当**降低分辨率**、限制 `solver_max_stars`、拉大 `solver_fullsolve_interval_frames`(实时模式)。 +- Tetra 解算在后台线程执行,避免阻塞事件循环(见 `asyncio.to_thread`)。 diff --git a/docs/development/pycharm-remote.md b/docs/development/pycharm-remote.md deleted file mode 100644 index aae9724..0000000 --- a/docs/development/pycharm-remote.md +++ /dev/null @@ -1,310 +0,0 @@ -# PyCharm Professional 远程开发配置指南 - -本指南适用于 **PyCharm Professional 2021.1.3** 版本 - -## 前置准备 - -### 1. Orange Pi Zero 2W 配置 - -```bash -# SSH 连接到 Orange Pi -ssh pi@orangepi.local # 或使用 IP 地址 - -# 更新系统 -sudo apt update && sudo apt upgrade -y - -# 安装必要工具 -sudo apt install -y python3.9 python3-pip python3-venv git - -# 安装 Poetry -curl -sSL https://install.python-poetry.org | python3 - -echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc -source ~/.bashrc - -# 验证安装 -poetry --version -``` - -### 2. Mac 本地配置 - -```bash -# 配置 SSH 免密登录(强烈推荐) -ssh-keygen -t ed25519 -C "ogscope-dev" -ssh-copy-id pi@orangepi.local - -# 配置 SSH config -cat >> ~/.ssh/config << EOF -Host orangepi - HostName orangepi.local # 或固定 IP - User pi - Port 22 - ForwardAgent yes - ServerAliveInterval 60 - ServerAliveCountMax 3 -EOF - -# 测试连接 -ssh orangepi -``` - -## PyCharm Professional 配置步骤 - -### 步骤 1: 配置远程解释器 - -1. **打开项目** - - 在 Mac 上用 PyCharm 打开 OGScope 项目目录 - -2. **添加远程解释器** - - `File` → `Settings` (macOS: `PyCharm` → `Preferences`) - - 导航到: `Project: OGScope` → `Python Interpreter` - - 点击右上角 ⚙️ 图标 → `Add...` - -3. **配置 SSH 连接** - - 选择 `SSH Interpreter` - - **New server configuration:** - - Host: `orangepi.local` (或 IP 地址) - - Port: `22` - - Username: `pi` - - 点击 `Next` - -4. **认证方式** - - 选择 `Key pair` - - Private key file: `~/.ssh/id_ed25519` - - 或选择 `Password` 输入密码 - - 点击 `Next` - -5. **选择解释器** - - Interpreter: `/home/pi/.local/bin/poetry` - - 或使用虚拟环境: `/home/pi/OGScope/.venv/bin/python` - - Sync folders: - - Local: `/Users/你的用户名/Desktop/ogs proj/OGScope` - - Remote: `/home/pi/OGScope` - - 点击 `Finish` - -### 步骤 2: 配置自动部署 - -1. **打开部署配置** - - `Tools` → `Deployment` → `Configuration` - -2. **添加 SFTP 服务器** - - 点击 `+` 添加服务器 - - Name: `Orange Pi Zero 2W` - - Type: `SFTP` - -3. **Connection 标签配置** - ``` - SSH configuration: orangepi (使用前面配置的) - Root path: /home/pi/OGScope - Web server URL: http://orangepi.local:8000 (可选) - ``` - -4. **Mappings 标签配置** - ``` - Local path: /Users/你的用户名/Desktop/ogs proj/OGScope - Deployment path: / - Web path: / - ``` - -5. **Excluded Paths 标签** (添加不需要同步的目录) - ``` - .venv - __pycache__ - .pytest_cache - .mypy_cache - *.pyc - .git - ``` - -6. **启用自动上传** - - `Tools` → `Deployment` → `Automatic Upload` (打勾) - - 或设置为 `On explicit save action` (Cmd+S 时上传) - -### 步骤 3: 配置运行/调试 - -1. **创建运行配置** - - `Run` → `Edit Configurations...` - - 点击 `+` → `Python` - -2. **配置参数** - ``` - Name: OGScope Main - Script path: (留空) - Module name: ogscope.main - Parameters: --host 0.0.0.0 --port 8000 --reload - Python interpreter: <选择之前配置的远程解释器> - Working directory: /home/pi/OGScope - ``` - -3. **环境变量** (可选) - ``` - OGSCOPE_ENV=development - LOG_LEVEL=DEBUG - ``` - -4. **远程调试配置** - - 确保 `Path mappings` 正确: - ``` - Local: /Users/你的用户名/Desktop/ogs proj/OGScope - Remote: /home/pi/OGScope - ``` - -### 步骤 4: 使用远程终端 - -1. **添加 SSH 会话** - - `Tools` → `Start SSH Session...` - - 选择 `orangepi` 配置 - -2. **或使用内置终端** - - 打开 Terminal 面板 (Alt+F12 或 ⌥F12) - - PyCharm 会自动连接到远程服务器 - -## 常用操作 - -### 同步文件 - -```bash -# 手动上传当前文件 -Tools → Deployment → Upload to Orange Pi Zero 2W - -# 上传整个项目 -右键项目根目录 → Deployment → Upload to Orange Pi Zero 2W - -# 从远程下载 -Tools → Deployment → Download from Orange Pi Zero 2W - -# 比较本地和远程 -Tools → Deployment → Compare with Deployed Version on Orange Pi Zero 2W -``` - -### 运行和调试 - -```bash -# 运行程序 -点击工具栏的 ▶️ 运行按钮 -或按 Shift+F10 (macOS: ^R) - -# 调试程序 -点击工具栏的 🐞 调试按钮 -或按 Shift+F9 (macOS: ^D) - -# 在代码中设置断点 -点击行号左侧设置断点 (红点) -``` - -### 远程 Poetry 管理 - -```python -# 在远程终端中执行 -poetry install # 安装依赖 -poetry add # 添加包 -poetry remove # 移除包 -poetry update # 更新依赖 -poetry shell # 激活虚拟环境 -``` - -## 常见问题 - -### 问题 1: 连接超时 - -**解决方案**: -```bash -# 检查 Orange Pi 网络 -ping orangepi.local - -# 检查 SSH 服务 -ssh orangepi -sudo systemctl status ssh - -# 增加 SSH 超时时间 -# 在 ~/.ssh/config 中添加: -ServerAliveInterval 60 -ServerAliveCountMax 3 -``` - -### 问题 2: 文件同步慢 - -**解决方案**: -```bash -# 方案1: 排除不必要的文件 -Tools → Deployment → Configuration → Excluded Paths -添加: .venv, .git, __pycache__, *.pyc - -# 方案2: 使用增量同步 -Tools → Deployment → Options -勾选: Upload changed files automatically - -# 方案3: 手动同步 -只同步修改的文件,避免全量上传 -``` - -### 问题 3: 远程解释器找不到包 - -**解决方案**: -```bash -# 在远程终端重新安装 -cd ~/OGScope -poetry install - -# 刷新 PyCharm 解释器缓存 -Settings → Project → Python Interpreter -点击 🔄 刷新按钮 -``` - -### 问题 4: 调试断点不生效 - -**解决方案**: -```bash -# 检查路径映射 -Run → Edit Configurations → Path mappings -确保本地和远程路径正确对应 - -# 重新同步项目 -Tools → Deployment → Sync with Deployed to Orange Pi Zero 2W -``` - -## 性能优化建议 - -### 1. 使用 .gitignore 和排除路径 - -确保 `.venv`, `__pycache__`, `.pytest_cache` 等目录不被同步 - -### 2. 启用智能同步 - -``` -Tools → Deployment → Options: -☑ Upload changed files automatically -☑ Skip external changes -``` - -### 3. 使用有线网络 - -如果 WiFi 不稳定,考虑使用 USB 网卡 + 有线连接 - -### 4. 本地开发,远程测试 - -```python -# 在本地快速开发和测试 -poetry run pytest tests/unit/ - -# 需要硬件时再同步到远程运行 -Tools → Deployment → Upload to Orange Pi Zero 2W -``` - -## 快捷键速查表 - -| 操作 | macOS | Windows/Linux | -|------|-------|---------------| -| 运行 | ^R | Shift+F10 | -| 调试 | ^D | Shift+F9 | -| 停止 | ⌘F2 | Ctrl+F2 | -| 同步文件 | ⌥⌘Y | Ctrl+Alt+Y | -| 远程终端 | ⌥F12 | Alt+F12 | -| 查找文件 | ⌘⇧O | Ctrl+Shift+N | - -## 下一步 - -配置完成后,可以开始开发了!参考: -- [FastAPI 开发指南](./fastapi-guide.md) -- [硬件接口开发](./hardware-interface.md) -- [测试指南](./testing-guide.md) - diff --git a/docs/development/testing-guide.md b/docs/development/testing-guide.md new file mode 100644 index 0000000..de86d0d --- /dev/null +++ b/docs/development/testing-guide.md @@ -0,0 +1,91 @@ +# OGScope 测试指南(小团队版) + +本文档用于 1-2 人开发团队的测试落地,目标不是追求高覆盖率,而是用最小成本防止高频改动造成回归。 + +## 1. 测试目标 + +- 保护关键链路:服务可启动、核心 API 可用、调试控制台关键功能可用 +- 减少回归排查时间:出现问题能快速定位是“代码逻辑”还是“硬件环境” +- 让测试可维护:每次改动只补充最相关的 1-2 个测试 + +## 2. 测试分层(推荐) + +### 2.1 Unit(默认) + +- 运行环境:本地或开发板均可 +- 特点:不依赖真实硬件,执行快 +- 方法:使用 `FakeCamera`、`monkeypatch`、临时目录 fixture + +### 2.2 Integration(可选) + +- 运行环境:本地优先,必要时开发板 +- 特点:覆盖模块协作(路由 + 服务 + 文件) + +### 2.3 Hardware(开发板专用) + +- 运行环境:开发板 +- 特点:只验证真实相机与系统依赖,不做大量业务分支覆盖 + +## 3. 当前最小测试网 + +已纳入的最小网包括: + +- `tests/unit/test_api.py` + - 根路径、健康检查、API 根接口、相机状态基础结构 +- `tests/unit/test_debug_presets_api.py` + - 预设空列表、保存、覆盖更新、删除 +- `tests/unit/test_debug_files_api.py` + - 文件列表、文件信息、删除联动删除 `.txt` 信息 +- `tests/unit/test_debug_camera_api.py` + - 调试相机状态、启动/停止、旋转、FPS、采样模式、图像质量、设置更新 +- `tests/conftest.py` + - `temp_debug_dir` fixture,确保测试不污染用户目录 + +## 4. 日常开发执行策略 + +### 4.1 本地改动后(每次) + +```bash +poetry run pytest -q +``` + +### 4.2 提交前(推荐) + +```bash +poetry run pytest -q +poetry run ruff check tests ogscope +``` + +### 4.3 开发板验证(涉及硬件改动时) + +```bash +sudo systemctl restart ogscope +sudo systemctl status ogscope +sudo journalctl -u ogscope -f +``` + +## 5. 编写新测试的规则(低压力) + +- 每次功能改动,至少补 1 个“成功路径”测试 +- 每次修 bug,必须补 1 个“回归测试” +- 优先测“最容易坏、影响最大”的接口 +- 不追求一次写全,按迭代慢慢扩 + +## 6. 推荐优先级(后续增量) + +下一批建议优先补: + +1. `/api/camera/*` 的 simulation 分支冒烟 +2. `DebugPresetService.apply_preset` 的异常路径 +3. `DebugCameraService.set_size/set_fps` 的失败分支 +4. 开发板上 3-5 个硬件 smoke 测试(启动、抓帧、重启后健康检查) + +## 7. 常见问题 + +### 7.1 为什么不直接写大量硬件测试? + +因为硬件测试慢、环境依赖重,不适合小团队高频提交。应把硬件测试集中在关键 smoke,业务分支放到 unit 层完成。 + +### 7.2 覆盖率只有 30% 左右是否可接受? + +在当前阶段可以接受。比“覆盖率数字”更重要的是:关键接口有稳定回归保护,且每次改动都能快速验证。 diff --git a/docs/development/wifi-nm.md b/docs/development/wifi-nm.md new file mode 100644 index 0000000..3a3fe67 --- /dev/null +++ b/docs/development/wifi-nm.md @@ -0,0 +1,145 @@ +# WiFi:STA / AP 与 NetworkManager + +本文档为 **Raspberry Pi OS** 上 OGScope **网络能力的唯一详解**:热点/STA 密码与访问、`network.env`、sudoers、`/debug/system`、Web API,以及 **开机网络引导**(`ogscope-network-boot`)与 **运行时 STA 回滚**(`wifi_sta_rollback_*`)的分工。开发板 **Poetry、`install.sh` / `board-update.sh` 总流程**见 [开发指南(部署速查)](README.md) **§0.2**、**§0.5**;以下按「用户说明 → 初始化 → 环境变量 → sudoers → API → 验证」展开。 + +## 用户操作说明(密码、访问与安全) + +以下为 **终端用户 / 现场操作** 常用信息;技术细节见后文各节。 + +### 默认热点(AP 模式) + +| 项目 | 说明 | +|------|------| +| **SSID** | `OGScope_xxxx`,其中 `xxxx` 为 **wlan0 MAC 地址后 4 位**(十六进制,小写),每台设备不同。可在设备标签或执行 `init` 后的日志 / `diag` 中确认。 | +| **密码(PSK)** | 固定为 **`ogscopeadmin`**(由 [`ogscope-network-init.sh`](../../scripts/ogscope-network-init.sh) 写入 NetworkManager,**非随机**)。 | +| **网关 / 固定地址** | 热点模式下无线侧一般为 **`192.168.4.1/24`**;NM 中 `OGScope-AP` 使用 **`ipv4.method shared`**(dnsmasq DHCP),避免客户端仅有 **169.254.x.x** 而无法访问网关。 | +| **Web 访问** | 手机/电脑连上该热点后,浏览器打开 **`http://192.168.4.1:<端口>`**;HTTP 端口以设备上 OGScope 配置为准(常见为 **8000**,见应用或 `ogscope` 服务环境变量)。 | +| **mDNS 主机名** | 初始化后主机名形如 **`ogscope-xxxx`**(`xxxx` 与 SSID 后缀一致),局域网内可尝试 **`http://ogscope-xxxx.local:<端口>/debug/system`**(需 **Avahi** 与 DNS 解析正常)。 | + +### 连接家中路由器(STA 模式) + +1. 连上 OGScope 热点后打开 **`/debug/system`**(系统调试页)。 +2. 使用 **「扫描 WiFi」**(由设备执行 `nmcli`,列表可能为空,见下)或 **「手动输入 SSID + 密码」**。 +3. 提交后设备会切到 STA 并尝试连接;若超时未拿到局域网 IPv4,会按配置 **自动切回 AP**,避免彻底失联。 + +**说明**:浏览器 **不能** 读取你手机里的 WiFi 列表;列表必须由 **树莓派上的 NetworkManager** 生成,或你 **手动输入** 家中 SSID/密码。 + +### 已保存的 WiFi 列表与「激活」 + +- **作用**:列出 NetworkManager 里 **已保存的 WiFi 连接**(不含热点 `OGScope-AP`)。**「激活」** 表示对该 profile 执行 `nmcli connection up`,在 **不切热点脚本路径** 的情况下直接连上该 SSID(适合以前连过、密码已保存在系统中的网络)。 +- **与「手动连接 / 扫描后连接」的区别**:后者会改 **`OGScope-STA`** 的 SSID/密码再切 STA;「激活」用于 **其他已命名连接**(若存在)。 +- **报错 `Not authorized to control networking`**:服务用户(如 `ogstartech`)默认受 **polkit** 限制,不能直接 `nmcli connection up`。初始化脚本会写入 **`/etc/sudoers.d/ogscope-nmcli`**,允许该用户 **免密执行 `nmcli` 二进制**;应用内对 `nmcli` 使用 **`sudo -n`**(见 `OGSCOPE_WIFI_NMCLI_USE_SUDO`,默认开启)。若仍报错,请执行 + `sudo ./scripts/ogscope-network-init.sh ensure-systemd`(会补写 `ogscope-nmcli` sudoers)或对照后文 **§3** 手动添加,然后 **`sudo systemctl restart ogscope`**。 + +### 注意事项(必读) + +1. **默认热点密码是公开的**(`ogscopeadmin`),仅适合现场调试与封闭环境。若长期暴露热点,请在 NetworkManager 中 **修改 `OGScope-AP` 的 PSK**,并自行记录;修改后需与现场人员同步。 +2. **单频网卡在 AP 模式下**,往往 **无法同时列出周边 WiFi**(扫描结果可能为 0);此时请 **直接手动输入** 家中 SSID 与密码。 +3. **从 STA 切回 AP** 或 **换网** 后,当前浏览器会话可能断开;请重新连接热点 `OGScope_xxxx` 或在本机局域网用 **mDNS / 路由器管理页** 查找设备 IP。 +4. **HTTPS 页面无法混合访问 HTTP API**:若用纯 HTTPS 入口访问,设备上的 **`/health` 局域网探测** 可能受限;优先使用 **HTTP** 同网段访问调试页(见页面提示)。 +5. 更新代码后若 WiFi 相关异常,需同步 **`ogscope-wifi-switch` 脚本** 到 `/usr/local/bin` 并 **重启 `ogscope` 服务**(见后文「验证 nmcli」与 `board-update.sh`)。 +6. **连上热点后手机/电脑只有 169.254.x.x**:多为旧版 **`OGScope-AP`** 使用 `ipv4.method manual`、未向客户端发 DHCP。请同步最新 `ogscope-network-init.sh` 后执行 **`sudo ./scripts/ogscope-network-init.sh init --yes`**(复用设备后缀、重建 NM 连接);或仅改连接: + `sudo nmcli connection modify OGScope-AP wifi-sec.proto rsn wifi-sec.pairwise ccmp wifi-sec.group ccmp ipv4.method shared ipv4.addresses 192.168.4.1/24`,再 `sudo nmcli connection down OGScope-AP && sudo nmcli connection up OGScope-AP`(接口名一般为 `wlan0`)。 + +### 安全与隐私(简要) + +- **`/etc/ogscope/network.env`** 含连接名与设备后缀,权限为 **600**,勿提交版本库。 +- **sudoers**:仅允许指定用户免密运行 **`/usr/local/bin/ogscope-wifi-switch`** 与 **`nmcli` 绝对路径**(`/etc/sudoers.d/ogscope-wifi`、`ogscope-nmcli`);勿改为无限制 `NOPASSWD: ALL`。 +- 现场请勿在不可信网络中开启长期 AP;生产环境请结合路由器 ACL、强密码与固件更新策略。 + +--- + +## 1. 推荐:一键初始化(`ogscope-network-init.sh`) + +完整 **`install.sh` 行为与可选环境变量**摘要见 [开发指南 §0.2](README.md#02-首次安装)。 + +安装脚本 [scripts/install.sh](../../scripts/install.sh) 会在部署阶段安装 `network-manager`、`avahi-daemon`,并执行: + +```bash +sudo env OGSCOPE_SERVICE_USER="$USER" ./scripts/ogscope-network-init.sh init --yes +``` + +- 根据 **wlan0 MAC 后 4 位十六进制** 生成热点 SSID:`OGScope_xxxx`,密码固定 **`ogscopeadmin`**。 +- 创建 NM 连接 **`OGScope-STA`**(占位,供 Web 填写 SSID/密码)与 **`OGScope-AP`**(`192.168.4.1/24`、`ipv4.method shared`、WPA2-only)。 +- 写入 **`/etc/ogscope/network.env`**(供 systemd `EnvironmentFile` 加载)。 +- 安装 **`/usr/local/bin/ogscope-wifi-switch`** 并配置 **sudoers**(免密执行该脚本;另写入 **`ogscope-nmcli`**,供 Web API 调用 **`nmcli`**,避免 polkit 拒绝)。 +- 设置主机名为 **`ogscope-xxxx`**,并同步 **`/etc/hosts`** 中 `127.0.1.1`(减轻 `sudo: unable to resolve host`)。 +- 写入 **systemd drop-in**:`/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf`,使 **`ogscope` 服务加载 `/etc/ogscope/network.env`**(与新版 [`install.sh`](../../scripts/install.sh) 主 unit 中的 `EnvironmentFile=-/etc/ogscope/network.env` 一致;**仅跑过旧版 install、未含该行的部署**此前会在 Web/API 中看到 `wifi_not_configured`)。 +- 便于 **`http://ogscope-xxxx.local:端口`** 访问(需 Avahi)。 +- **[`install.sh`](../../scripts/install.sh)** 还会安装 **`ogscope-network-boot.service`**(**root**、`Type=oneshot`、`Before=ogscope.service`),执行 [`scripts/ogscope-network-boot.sh`](../../scripts/ogscope-network-boot.sh):开机后若 **默认 IPv4 路由已在非无线口**(例如有线已联网)则**跳过**;否则在 **`OGSCOPE_BOOT_STA_WAIT_SEC`**(默认 55)秒内轮询 **`wlan0` 是否获得非 169.254 的 IPv4**(与 Python 侧 `sta_interface_has_usable_ipv4` 语义一致);仍无则尝试 **`nmcli connection up` STA**(次数由 **`OGSCOPE_BOOT_STA_UP_RETRIES`** 等控制),最后仍失败则 **拉起 AP**,避免冷启动无网。跳过该单元:`OGSCOPE_SKIP_NETWORK_BOOT=1 ./scripts/install.sh`。日志:`journalctl -u ogscope-network-boot -b`。 +- **与进程内 STA 回滚的区别**:**开机引导**不依赖 `ogscope` 进程;应用内 **`wifi_sta_rollback_*`** 仅在用户通过 Web/API **切 STA 成功后** 监视超时并回滚 AP。 + +跳过初始化:`OGSCOPE_SKIP_NETWORK_INIT=1 ./scripts/install.sh` + +**`init` 与 SSH**:脚本会删除并重建 `wlan0` 上的 NetworkManager 连接;若你通过 **Wi‑Fi 上的 SSH** 执行,会话很可能在 `create_nm_connections` 阶段即断开,属正常现象。请改用 **有线网口**、**串口** 或 **直连键盘/显示器** 执行 `init`,或断线后连接热点 `OGScope_xxxx` 再访问 Web。 + +诊断与重置: + +```bash +sudo ./scripts/ogscope-network-init.sh diag +sudo ./scripts/ogscope-network-init.sh ensure-systemd # 补 systemd drop-in + /etc/hosts 127.0.1.1(需已有 network.env) +sudo ./scripts/ensure-ogscope-systemd-network-env.sh # 同上,薄封装 +sudo ./scripts/ogscope-network-init.sh reset # 交互确认 +sudo ./scripts/ogscope-network-init.sh reset --yes +``` + +### 已部署过旧代码、只缺 WiFi 或只缺 systemd 加载 env + +1. 同步代码后若 **`/etc/ogscope/network.env` 已存在**,但控制台仍显示 **`wifi_not_configured`**:先执行 + `sudo ./scripts/ogscope-network-init.sh ensure-systemd`,再 **`sudo systemctl restart ogscope`**。 + (`diag` 会检查 `systemctl cat ogscope` 是否包含对 `network.env` 的 `EnvironmentFile`。) +2. 若从未生成 `network.env`,仍用 **`init`** 完整初始化。 +3. **`/etc/hosts` 未随主机名更新** 时,`sudo` 可能出现 `unable to resolve host`;重新 **`init`** 会写入 `127.0.1.1 ogscope-xxxx`,或手动编辑 `/etc/hosts`。 + +## 2. 环境变量(`/etc/ogscope/network.env` 或 `.env`) + +与 [ogscope/config.py](../../ogscope/config.py) 中 `OGSCOPE_` 前缀一致: + +| 变量 | 含义 | +|------|------| +| `OGSCOPE_WIFI_STA_CONNECTION` | STA 连接名(默认 `OGScope-STA`) | +| `OGSCOPE_WIFI_AP_CONNECTION` | AP 连接名(默认 `OGScope-AP`) | +| `OGSCOPE_WIFI_INTERFACE` | 无线接口,默认 `wlan0` | +| `OGSCOPE_DEVICE_ID_SUFFIX` | 4 位 hex 后缀(与热点 SSID 一致) | +| `OGSCOPE_WIFI_AP_SSID` | 热点 SSID 全文(如 `OGScope_a1b2`) | +| `OGSCOPE_WIFI_NMCLI_USE_SUDO` | 默认 `true`:非 root 进程用 `sudo -n nmcli`;需 **`ogscope-nmcli` sudoers** | + +## 3. 手动安装切换脚本与 sudoers + +```bash +sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +sudo visudo -f /etc/sudoers.d/ogscope-wifi +# Web 扫描 / 激活已保存 WiFi 需 nmcli 免密(路径以 which 为准): +# sudo visudo -f /etc/sudoers.d/ogscope-nmcli +``` + +示例: + +``` +ogstartech ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +``` + +`nmcli`(与 `init` 脚本自动生成的一致,`which nmcli` 多为 `/usr/bin/nmcli`): + +``` +ogstartech ALL=(ALL) NOPASSWD: /usr/bin/nmcli +``` + +## 4. Web API 与 STA 回滚 + +- `GET /api/network/wifi/scan`:由设备执行 `nmcli` 扫描(**浏览器无法在客户端扫周围 WiFi**,无 Web API)。响应中 `networks` 为列表,**`hint` 可为空**;若当前为热点(AP)模式且列表为空,会给出说明(单频网卡常无法同时列出周边 BSS)。 +- `POST /api/network/wifi/sta/connect`:填写 SSID/密码后切 STA;若 **`wifi_sta_rollback_timeout_seconds`** 内未获得可用 IPv4,则**自动切回 AP**,防止失联。 +- `GET /api/network/wifi/profiles`:已保存的 WiFi 连接(NetworkManager 持久化)。 +- `POST /api/network/wifi/profile/activate`:**激活**某条已保存连接(`nmcli connection up`);需 **`ogscope-nmcli` sudoers** 或等价 polkit 授权,否则会报 **Not authorized**。 +- 系统调试页:`/debug/system`(引导、mDNS 提示、局域网 `/health` 探测)。 +- 环境变量 **`OGSCOPE_WIFI_NMCLI_USE_SUDO`**(默认 `true`):为 `false` 时直接调用 `nmcli`(仅当运行用户已被 polkit 允许控制 NM 时使用)。 + +## 5. 验证 nmcli + +```bash +sudo /usr/local/bin/ogscope-wifi-switch status +sudo /usr/local/bin/ogscope-wifi-switch ap +sudo /usr/local/bin/ogscope-wifi-switch sta +``` + +脚本在 **未继承环境变量** 时会自动 `source /etc/ogscope/network.env`(与 systemd 一致)。若仍用旧习惯 `sudo -E`,部分系统 sudoers 会报 **user not allowed to preserve the environment**,可省略 `-E`。 diff --git a/init_git.sh b/init_git.sh deleted file mode 100755 index 78966e9..0000000 --- a/init_git.sh +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/bash -# OGScope Git 仓库初始化脚本 - -set -e - -echo "======================================" -echo " OGScope Git 仓库初始化" -echo "======================================" - -# 确认当前目录 -echo "当前目录: $(pwd)" -read -p "确认在正确的项目目录中吗?(y/n) " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "已取消" - exit 1 -fi - -# 初始化 Git 仓库 -if [ -d ".git" ]; then - echo "⚠️ Git 仓库已存在" - read -p "是否重新初始化?这将删除现有 Git 历史!(y/n) " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - rm -rf .git - git init - echo "✅ 已重新初始化 Git 仓库" - else - echo "保持现有 Git 仓库" - fi -else - git init - echo "✅ Git 仓库已初始化" -fi - -# 添加所有文件 -echo "📦 添加文件到暂存区..." -git add . - -# 首次提交 -echo "💾 创建初始提交..." -git commit -m "Initial commit: OGScope project structure - -- Setup Poetry project with pyproject.toml -- Add FastAPI web service framework -- Create basic project structure -- Add PyCharm remote development guide -- Setup GitHub Actions CI/CD -- Add comprehensive documentation -- Include installation scripts for Orange Pi - -Project Features: -- Electronic Polar Scope for astrophotography -- Orange Pi Zero 2W + IMX327 camera -- Web control interface -- SPI LCD display support (planned) -- INDI integration (planned) -" - -echo "" -echo "======================================" -echo " ✅ Git 仓库初始化完成!" -echo "======================================" -echo "" -echo "下一步:" -echo "" -echo "1. 在 GitHub 上创建新仓库:" -echo " https://github.com/new" -echo " 仓库名: OGScope" -echo " ⚠️ 不要初始化 README、.gitignore 或 LICENSE" -echo "" -echo "2. 添加远程仓库并推送:" -echo " git remote add origin https://github.com/your-username/OGScope.git" -echo " git branch -M main" -echo " git push -u origin main" -echo "" -echo "3. 或使用 SSH:" -echo " git remote add origin git@github.com:your-username/OGScope.git" -echo " git branch -M main" -echo " git push -u origin main" -echo "" - diff --git a/ogscope/__init__.py b/ogscope/__init__.py index 191fc54..3657edc 100644 --- a/ogscope/__init__.py +++ b/ogscope/__init__.py @@ -1,10 +1,17 @@ """ OGScope - 电子极轴镜 -基于 Orange Pi Zero 2W 和 IMX327 的智能极轴校准系统 +基于 Raspberry Pi Zero 2W 和 IMX327 的智能极轴校准系统 """ +# 使 vendored tetra3 可被 import / Make vendored tetra3 importable +import sys +from pathlib import Path + +_vendor_root = Path(__file__).resolve().parent / "vendor" +if _vendor_root.is_dir() and str(_vendor_root) not in sys.path: + sys.path.insert(0, str(_vendor_root)) + from ogscope.__version__ import __version__ __all__ = ["__version__"] - diff --git a/ogscope/__version__.py b/ogscope/__version__.py index d0304f1..bbc8789 100644 --- a/ogscope/__version__.py +++ b/ogscope/__version__.py @@ -1,4 +1,3 @@ -"""版本信息""" +"""版本信息 / Version information""" __version__ = "0.1.0" - diff --git a/ogscope/algorithms/plate_solve/__init__.py b/ogscope/algorithms/plate_solve/__init__.py new file mode 100644 index 0000000..82276d8 --- /dev/null +++ b/ogscope/algorithms/plate_solve/__init__.py @@ -0,0 +1,23 @@ +""" +星图解算模块导出 / Plate solving module exports +""" + +from ogscope.algorithms.plate_solve.solver import ( + CentroidExtractionParams, + PlateSolver, + SolveResult, + centroid_extraction_preview, + merge_centroid_params, + reset_tetra3_singleton_for_tests, + resize_bgr_for_extraction, +) + +__all__ = [ + "CentroidExtractionParams", + "PlateSolver", + "SolveResult", + "centroid_extraction_preview", + "merge_centroid_params", + "reset_tetra3_singleton_for_tests", + "resize_bgr_for_extraction", +] diff --git a/ogscope/algorithms/plate_solve/solver.py b/ogscope/algorithms/plate_solve/solver.py new file mode 100644 index 0000000..7eae0f6 --- /dev/null +++ b/ogscope/algorithms/plate_solve/solver.py @@ -0,0 +1,685 @@ +""" +基于 Tetra3 (Cedar-Solve) 的星图解算 / Plate solving via Tetra3 (Cedar-Solve) +""" + +from __future__ import annotations + +import base64 +import dataclasses +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np +from PIL import Image + +from ogscope.algorithms.star_extract import StarPoint +from ogscope.config import Settings, get_settings + +_STATUS_NAMES: dict[int, str] = { + 1: "MATCH_FOUND", + 2: "NO_MATCH", + 3: "TIMEOUT", + 4: "CANCELLED", + 5: "TOO_FEW", +} + + +def _json_safe(obj: Any) -> Any: + """将 numpy 标量/数组等转为 JSON/FastAPI 可序列化类型 / JSON-serializable conversion for API.""" + if obj is None or isinstance(obj, (str, bool, int, float)): + return obj + if isinstance(obj, np.generic): + return obj.item() + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, dict): + return {str(k): _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_safe(x) for x in obj] + if isinstance(obj, set): + return [_json_safe(x) for x in obj] + return obj + + +_tetra_lock = threading.Lock() +_tetra_instance: Any = None +_tetra_load_key: str | None = None + + +def _resolve_database_path(settings: Settings) -> Path | str: + """选择图案库路径:环境配置 > data/plate_solve > 包内默认名 / Resolve pattern DB path.""" + if settings.solver_tetra_database_path is not None: + return settings.solver_tetra_database_path.expanduser().resolve() + candidate = settings.plate_solve_dir / "default_database.npz" + if candidate.is_file(): + return candidate + return "default_database" + + +def _get_tetra3(settings: Settings) -> Any: + """懒加载单例 / Lazy singleton Tetra3.""" + global _tetra_instance, _tetra_load_key + key = str(_resolve_database_path(settings)) + with _tetra_lock: + if _tetra_instance is not None and _tetra_load_key == key: + return _tetra_instance + from tetra3 import Tetra3 # noqa: PLC0415 — after vendor path + + load_arg: Path | str = _resolve_database_path(settings) + _tetra_instance = Tetra3(load_arg) + _tetra_load_key = key + return _tetra_instance + + +def reset_tetra3_singleton_for_tests() -> None: + """测试用:清空单例 / Tests: clear singleton.""" + global _tetra_instance, _tetra_load_key + with _tetra_lock: + _tetra_instance = None + _tetra_load_key = None + + +@dataclass(slots=True) +class CentroidExtractionParams: + """Tetra3 提星参数 / Parameters for get_centroids_from_image.""" + + sigma: float = 2.5 + max_area: int = 400 + min_area: int = 5 + filtsize: int = 25 + binary_open: bool = True + bg_sub_mode: str = "local_mean" + sigma_mode: str = "global_root_square" + max_axis_ratio: float | None = None + + @classmethod + def from_settings(cls, settings: Settings) -> CentroidExtractionParams: + """从应用配置构造 / Build from application settings.""" + return cls( + sigma=settings.solver_centroid_sigma, + max_area=settings.solver_centroid_max_area, + min_area=settings.solver_centroid_min_area, + filtsize=settings.solver_centroid_filtsize, + binary_open=settings.solver_centroid_binary_open, + bg_sub_mode=settings.solver_centroid_bg_sub_mode, + sigma_mode=settings.solver_centroid_sigma_mode, + max_axis_ratio=settings.solver_centroid_max_axis_ratio, + ) + + def to_get_centroids_kwargs(self) -> dict[str, Any]: + """传给 get_centroids_from_image 的关键字 / Keyword args for Tetra3 centroiding.""" + kwargs: dict[str, Any] = { + "filtsize": self.filtsize, + "bg_sub_mode": self.bg_sub_mode, + "sigma_mode": self.sigma_mode, + "sigma": self.sigma, + "binary_open": self.binary_open, + "max_area": self.max_area, + "min_area": self.min_area, + } + if self.max_axis_ratio is not None: + kwargs["max_axis_ratio"] = self.max_axis_ratio + return kwargs + + +def merge_centroid_params( + base: CentroidExtractionParams, + overrides: dict[str, Any], +) -> CentroidExtractionParams: + """用非 None 字段覆盖 base / Overlay non-None keys onto base.""" + allowed = {f.name for f in dataclasses.fields(CentroidExtractionParams)} + filtered = {k: v for k, v in overrides.items() if k in allowed and v is not None} + return dataclasses.replace(base, **filtered) + + +def resize_bgr_for_extraction( + frame_bgr: np.ndarray, max_image_side: int +) -> tuple[np.ndarray, tuple[int, int]]: + """与解算相同的缩放,返回 (BGR, 原始高宽) / Same resize as solve; returns BGR and original shape.""" + h0, w0 = int(frame_bgr.shape[0]), int(frame_bgr.shape[1]) + img = frame_bgr + if max(h0, w0) > max_image_side: + sc = max_image_side / float(max(h0, w0)) + img = cv2.resize( + frame_bgr, + (max(1, int(w0 * sc)), max(1, int(h0 * sc))), + interpolation=cv2.INTER_AREA, + ) + return img, (h0, w0) + + +def subtract_large_scale_background_bgr( + frame_bgr: np.ndarray, + *, + downsample_max_side: int, +) -> np.ndarray: + """低分辨率估计大尺度背景并做亮度校正,减轻角部光晕等渐变 / Fast large-scale flat removal. + + 在小图上高斯平滑得到低频背景,上采样后与灰度相减,再按比例映射回 BGR,便于 Tetra3 提星。 + Estimates low-frequency background on a downscaled image, subtracts in luminance, scales RGB. + """ + if frame_bgr.ndim != 3 or frame_bgr.shape[2] != 3: + return frame_bgr + h, w = int(frame_bgr.shape[0]), int(frame_bgr.shape[1]) + gray = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32) + side = max(h, w) + sc = min(1.0, float(downsample_max_side) / float(side)) + sw = max(1, int(round(w * sc))) + sh = max(1, int(round(h * sc))) + small = cv2.resize(gray, (sw, sh), interpolation=cv2.INTER_AREA) + sigma_s = max(2.0, float(min(sw, sh)) / 32.0) + bg_small = cv2.GaussianBlur(small, (0, 0), sigmaX=sigma_s, sigmaY=sigma_s) + bg = cv2.resize(bg_small, (w, h), interpolation=cv2.INTER_LINEAR).astype(np.float32) + mean_gray = float(np.mean(gray)) + corr = gray - bg + mean_gray + corr = np.clip(corr, 1e-3, 255.0) + ratio = corr / np.maximum(gray, 1e-3) + ratio = np.clip(ratio, 0.0, 4.0) + out = frame_bgr.astype(np.float32) * ratio[..., np.newaxis] + return np.clip(np.round(out), 0, 255).astype(np.uint8) + + +def centroid_extraction_preview( + frame_bgr: np.ndarray, + *, + max_stars: int, + centroid_params: CentroidExtractionParams, + max_image_side: int, + large_scale_bg_subtract: bool = False, + downsample_max_side: int = 256, +) -> dict[str, Any]: + """提星预览:二值掩膜 PNG(base64),不解算 Tetra3 / Preview extraction mask without plate solve.""" + from tetra3 import get_centroids_from_image # noqa: PLC0415 + + img, (h0, w0) = resize_bgr_for_extraction(frame_bgr, max_image_side) + if large_scale_bg_subtract: + img = subtract_large_scale_background_bgr( + img, downsample_max_side=downsample_max_side + ) + height, width = int(img.shape[0]), int(img.shape[1]) + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(rgb) + kwargs = centroid_params.to_get_centroids_kwargs() + t0 = time.perf_counter() + try: + centroids, images_dict = get_centroids_from_image( + pil_image, + max_returned=max_stars, + return_images=True, + **kwargs, + ) + except (OSError, ValueError, RuntimeError) as exc: + return { + "success": False, + "error": str(exc), + "detected_stars": 0, + "t_extract_ms": None, + "binary_mask_png_base64": None, + "solve_width": width, + "solve_height": height, + "original_width": w0, + "original_height": h0, + } + t_extract_ms = (time.perf_counter() - t0) * 1000.0 + detected = int(len(centroids)) + mask = images_dict.get("binary_mask") + b64: str | None = None + if mask is not None: + mask_u8 = (np.asarray(mask, dtype=np.uint8) * 255).astype(np.uint8) + ok, buf = cv2.imencode(".png", mask_u8) + if ok: + b64 = base64.b64encode(buf.tobytes()).decode("ascii") + return { + "success": True, + "detected_stars": detected, + "t_extract_ms": round(t_extract_ms, 3), + "binary_mask_png_base64": b64, + "solve_width": width, + "solve_height": height, + "original_width": w0, + "original_height": h0, + } + + +@dataclass(slots=True) +class SolveResult: + """解算结果(Tetra3)/ Tetra3 solving result""" + + ra_deg: float + dec_deg: float + detected_stars: int + solve_source: str + status: str + status_code: int | None + roll_deg: float | None + fov_deg: float | None + matches: int | None + prob: float | None + rmse_arcsec: float | None + t_solve_ms: float | None + t_extract_ms: float | None + t_preprocess_ms: float | None + large_scale_bg_subtract: bool = False + raw: dict[str, Any] = field(default_factory=dict) + # 原图像素系下的叠加数据(与 Canvas x,y 一致)/ Overlay in original image pixels (Canvas x,y) + solve_overlay: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + base = { + "ra_deg": self.ra_deg, + "dec_deg": self.dec_deg, + "detected_stars": self.detected_stars, + "solve_source": self.solve_source, + "status": self.status, + "status_code": self.status_code, + "roll_deg": self.roll_deg, + "fov_deg": self.fov_deg, + "matches": self.matches, + "prob": self.prob, + "rmse_arcsec": self.rmse_arcsec, + "t_solve_ms": self.t_solve_ms, + "t_extract_ms": self.t_extract_ms, + "t_preprocess_ms": self.t_preprocess_ms, + "large_scale_bg_subtract": self.large_scale_bg_subtract, + } + if self.solve_overlay is not None: + base["solve_overlay"] = _json_safe(self.solve_overlay) + if self.raw: + # Tetra3 原始字段含 numpy 标量(如 uint16),FastAPI 无法直接 JSON 编码 / Tetra3 raw may contain numpy scalars + base["tetra"] = _json_safe(self.raw) + return base + + +class PlateSolver: + """Tetra3 星图解算封装 / Tetra3 plate solver wrapper""" + + def __init__( + self, + fov_deg: float = 16.0, + fov_max_error_deg: float | None = None, + solve_timeout_ms: int = 8000, + ) -> None: + self.fov_deg = fov_deg + self.fov_max_error_deg = fov_max_error_deg + self.solve_timeout_ms = solve_timeout_ms + + def _tetra(self) -> Any: + return _get_tetra3(get_settings()) + + def solve( + self, + stars: list[StarPoint], + frame_shape: tuple[int, ...], + hint_ra_deg: float = 0.0, + hint_dec_deg: float = 0.0, + solve_source: str = "full", + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + ) -> SolveResult: + """解算画面中心赤道坐标 / Solve frame center RA/Dec. + + hint_ra_deg / hint_dec_deg 对 Tetra3 无影响,仅保留 API 兼容 / Hints ignored by Tetra3. + """ + del hint_ra_deg, hint_dec_deg + height, width = int(frame_shape[0]), int(frame_shape[1]) + fov_est = float(fov_estimate if fov_estimate is not None else self.fov_deg) + fov_err = fov_max_error if fov_max_error is not None else self.fov_max_error_deg + timeout = float( + solve_timeout_ms if solve_timeout_ms is not None else self.solve_timeout_ms + ) + + if len(stars) < 4: + sorted_stars = sorted(stars, key=lambda s: s.flux, reverse=True) + cyx = np.array([[s.y, s.x] for s in sorted_stars], dtype=np.float64) + overlay = ( + _make_solve_overlay({}, cyx, (height, width), (height, width)) + if len(cyx) > 0 + else None + ) + return SolveResult( + ra_deg=0.0, + dec_deg=0.0, + detected_stars=len(stars), + solve_source=solve_source, + status="TOO_FEW", + status_code=5, + roll_deg=None, + fov_deg=None, + matches=None, + prob=None, + rmse_arcsec=None, + t_solve_ms=None, + t_extract_ms=None, + t_preprocess_ms=None, + large_scale_bg_subtract=False, + raw={"reason": "need_at_least_4_stars"}, + solve_overlay=overlay, + ) + + sorted_stars = sorted(stars, key=lambda s: s.flux, reverse=True) + centroids = np.array([[s.y, s.x] for s in sorted_stars], dtype=np.float64) + + try: + t3 = self._tetra() + out = t3.solve_from_centroids( + centroids, + (height, width), + fov_estimate=fov_est, + fov_max_error=fov_err, + solve_timeout=timeout, + return_matches=True, + ) + except OSError as exc: + return SolveResult( + ra_deg=0.0, + dec_deg=0.0, + detected_stars=len(stars), + solve_source=solve_source, + status="DATABASE_ERROR", + status_code=None, + roll_deg=None, + fov_deg=None, + matches=None, + prob=None, + rmse_arcsec=None, + t_solve_ms=None, + t_extract_ms=None, + t_preprocess_ms=None, + large_scale_bg_subtract=False, + raw={"error": str(exc)}, + ) + + return _tetra_dict_to_result( + out, + len(stars), + solve_source, + centroids_yx=centroids, + frame_shape_original=(height, width), + solve_shape=(height, width), + ) + + def solve_from_bgr_frame( + self, + frame_bgr: np.ndarray, + max_stars: int, + hint_ra_deg: float = 0.0, + hint_dec_deg: float = 0.0, + solve_source: str = "full", + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + max_image_side: int | None = None, + centroid_params: CentroidExtractionParams | None = None, + large_scale_bg_subtract: bool = False, + ) -> SolveResult: + """与 Tetra3 ``solve_from_image`` 等价:内置 ``get_centroids_from_image`` + ``solve_from_centroids``. + + Cedar-Solve / 官方示例走此提星链(局部背景减除、σ 阈值、连通域矩心),非 OpenCV OTSU。 + Same pipeline as Tetra3 ``solve_from_image`` (local bg, sigma threshold, scipy labeling). + 可选在提星前做大尺度背景减除(角部光晕等)/ Optional large-scale BG flattening before centroiding. + """ + del hint_ra_deg, hint_dec_deg + from tetra3 import get_centroids_from_image # noqa: PLC0415 — vendor path + + settings = get_settings() + side_cap = ( + int(max_image_side) + if max_image_side is not None + else int(settings.solver_max_image_side) + ) + params = ( + centroid_params + if centroid_params is not None + else CentroidExtractionParams.from_settings(settings) + ) + t0_preprocess = time.perf_counter() + img, (h0, w0) = resize_bgr_for_extraction(frame_bgr, side_cap) + if large_scale_bg_subtract: + img = subtract_large_scale_background_bgr( + img, + downsample_max_side=int(settings.solver_large_scale_bg_downsample), + ) + height, width = int(img.shape[0]), int(img.shape[1]) + rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(rgb) + t_preprocess_ms = (time.perf_counter() - t0_preprocess) * 1000.0 + + fov_est = float(fov_estimate if fov_estimate is not None else self.fov_deg) + fov_err = fov_max_error if fov_max_error is not None else self.fov_max_error_deg + timeout = float( + solve_timeout_ms if solve_timeout_ms is not None else self.solve_timeout_ms + ) + + centroid_kw = params.to_get_centroids_kwargs() + t0 = time.perf_counter() + try: + centroids = get_centroids_from_image( + pil_image, + max_returned=max_stars, + **centroid_kw, + ) + except (OSError, ValueError, RuntimeError) as exc: + return SolveResult( + ra_deg=0.0, + dec_deg=0.0, + detected_stars=0, + solve_source=solve_source, + status="EXTRACTION_ERROR", + status_code=None, + roll_deg=None, + fov_deg=None, + matches=None, + prob=None, + rmse_arcsec=None, + t_solve_ms=None, + t_extract_ms=None, + t_preprocess_ms=t_preprocess_ms, + large_scale_bg_subtract=large_scale_bg_subtract, + raw={"error": str(exc)}, + ) + t_extract_ms = (time.perf_counter() - t0) * 1000.0 + + detected = int(len(centroids)) + if detected < 4: + cyx = np.asarray(centroids, dtype=np.float64) + overlay = ( + _make_solve_overlay({}, cyx, (h0, w0), (height, width)) + if len(cyx) > 0 + else None + ) + return SolveResult( + ra_deg=0.0, + dec_deg=0.0, + detected_stars=detected, + solve_source=solve_source, + status="TOO_FEW", + status_code=5, + roll_deg=None, + fov_deg=None, + matches=None, + prob=None, + rmse_arcsec=None, + t_solve_ms=None, + t_extract_ms=t_extract_ms, + t_preprocess_ms=t_preprocess_ms, + large_scale_bg_subtract=large_scale_bg_subtract, + raw={"reason": "need_at_least_4_stars"}, + solve_overlay=overlay, + ) + + try: + t3 = self._tetra() + out = t3.solve_from_centroids( + centroids, + (height, width), + fov_estimate=fov_est, + fov_max_error=fov_err, + solve_timeout=timeout, + return_matches=True, + ) + except OSError as exc: + return SolveResult( + ra_deg=0.0, + dec_deg=0.0, + detected_stars=detected, + solve_source=solve_source, + status="DATABASE_ERROR", + status_code=None, + roll_deg=None, + fov_deg=None, + matches=None, + prob=None, + rmse_arcsec=None, + t_solve_ms=None, + t_extract_ms=t_extract_ms, + t_preprocess_ms=t_preprocess_ms, + large_scale_bg_subtract=large_scale_bg_subtract, + raw={"error": str(exc)}, + ) + + out["T_extract"] = t_extract_ms + out["T_preprocess"] = t_preprocess_ms + return _tetra_dict_to_result( + out, + detected, + solve_source, + centroids_yx=np.asarray(centroids, dtype=np.float64), + frame_shape_original=(h0, w0), + solve_shape=(height, width), + large_scale_bg_subtract=large_scale_bg_subtract, + ) + + +def _make_solve_overlay( + tetra_out: dict[str, Any], + centroids_yx: np.ndarray, + frame_shape_original: tuple[int, int], + solve_shape: tuple[int, int], +) -> dict[str, Any] | None: + """从 Tetra 输出与质心构造 solve_overlay(原图 x,y 像素)/ Build solve_overlay in original pixels.""" + h0, w0 = int(frame_shape_original[0]), int(frame_shape_original[1]) + h1, w1 = int(solve_shape[0]), int(solve_shape[1]) + if h1 <= 0 or w1 <= 0: + return None + sx = w0 / float(w1) + sy = h0 / float(h1) + + stars_all: list[dict[str, float]] = [] + arr = np.asarray(centroids_yx, dtype=np.float64) + if arr.size > 0 and arr.ndim == 2 and arr.shape[1] >= 2: + for row in arr: + y_s, x_s = float(row[0]), float(row[1]) + stars_all.append({"x": x_s * sx, "y": y_s * sy}) + + stars_matched: list[dict[str, Any]] = [] + raw_matched = tetra_out.get("matched_centroids") + raw_catalog = tetra_out.get("matched_stars") + raw_cat_ids = tetra_out.get("matched_catID") + if raw_matched: + for i, mc in enumerate(raw_matched): + y_s, x_s = float(mc[0]), float(mc[1]) + entry: dict[str, Any] = {"x": x_s * sx, "y": y_s * sy} + if raw_catalog is not None and i < len(raw_catalog): + ms = raw_catalog[i] + entry["ra_deg"] = float(ms[0]) + entry["dec_deg"] = float(ms[1]) + entry["mag"] = float(ms[2]) + if raw_cat_ids is not None and i < len(raw_cat_ids): + cid = raw_cat_ids[i] + entry["cat_id"] = _json_safe(cid) if cid is not None else None + stars_matched.append(entry) + + stars_pattern: list[dict[str, float]] = [] + raw_pat = tetra_out.get("pattern_centroids") + if raw_pat: + for pc in raw_pat: + y_s, x_s = float(pc[0]), float(pc[1]) + stars_pattern.append({"x": x_s * sx, "y": y_s * sy}) + + return { + "frame_shape": [h0, w0], + "stars_matched": stars_matched, + "stars_pattern": stars_pattern, + "stars_all_centroids": stars_all, + } + + +def _tetra_dict_to_result( + out: dict[str, Any], + detected_stars: int, + solve_source: str, + *, + centroids_yx: np.ndarray | None = None, + frame_shape_original: tuple[int, int] | None = None, + solve_shape: tuple[int, int] | None = None, + large_scale_bg_subtract: bool = False, +) -> SolveResult: + """Tetra 返回 dict → SolveResult / Map Tetra output dict to SolveResult.""" + st = out.get("status") + code = int(st) if st is not None else None + name = _STATUS_NAMES.get(code, str(st)) if code is not None else "UNKNOWN" + + ra = out.get("RA") + dec = out.get("Dec") + ra_f = float(ra) if ra is not None else 0.0 + dec_f = float(dec) if dec is not None else 0.0 + + raw = {k: v for k, v in out.items() if k not in ("RA", "Dec")} + + overlay: dict[str, Any] | None = None + if ( + centroids_yx is not None + and frame_shape_original is not None + and solve_shape is not None + ): + overlay = _make_solve_overlay( + out, centroids_yx, frame_shape_original, solve_shape + ) + + return SolveResult( + ra_deg=ra_f, + dec_deg=dec_f, + detected_stars=detected_stars, + solve_source=solve_source, + status=name, + status_code=code, + roll_deg=_maybe_float(out.get("Roll")), + fov_deg=_maybe_float(out.get("FOV")), + matches=_maybe_int(out.get("Matches")), + prob=_maybe_float(out.get("Prob")), + rmse_arcsec=_maybe_float(out.get("RMSE")), + t_solve_ms=_maybe_float(out.get("T_solve")), + t_extract_ms=_maybe_float(out.get("T_extract")), + t_preprocess_ms=_maybe_float(out.get("T_preprocess")), + large_scale_bg_subtract=large_scale_bg_subtract, + raw=raw, + solve_overlay=overlay, + ) + + +def warmup_tetra3() -> None: + """预热 Tetra3 单例与数据库,降低首轮解算延迟 / Warm up Tetra3 singleton to reduce first-solve latency.""" + _get_tetra3(get_settings()) + + +def _maybe_float(v: Any) -> float | None: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +def _maybe_int(v: Any) -> int | None: + if v is None: + return None + try: + return int(v) + except (TypeError, ValueError): + return None diff --git a/ogscope/algorithms/star_extract/__init__.py b/ogscope/algorithms/star_extract/__init__.py new file mode 100644 index 0000000..a8d0b56 --- /dev/null +++ b/ogscope/algorithms/star_extract/__init__.py @@ -0,0 +1,7 @@ +""" +星点提取模块导出 / Star extraction module exports +""" + +from ogscope.algorithms.star_extract.extractor import StarExtractor, StarPoint + +__all__ = ["StarExtractor", "StarPoint"] diff --git a/ogscope/algorithms/star_extract/extractor.py b/ogscope/algorithms/star_extract/extractor.py new file mode 100644 index 0000000..79ebc22 --- /dev/null +++ b/ogscope/algorithms/star_extract/extractor.py @@ -0,0 +1,123 @@ +""" +星点提取器 / Star extractor +""" + +from __future__ import annotations + +import heapq +import math +from dataclasses import dataclass +from typing import Any + +import cv2 +import numpy as np + + +@dataclass(slots=True) +class StarPoint: + """星点数据 / Star point data""" + + x: float + y: float + flux: float + area: float + + def to_dict(self) -> dict[str, Any]: + return {"x": self.x, "y": self.y, "flux": self.flux, "area": self.area} + + +class StarExtractor: + """简单星点提取 / Lightweight star extraction""" + + # 大图先缩小再提星,降低内存与轮廓数量 / Downscale before extract (RAM + contour count on SBCs) + _max_input_side: int = 1920 + # 椒盐噪声多时 OTSU 轮廓可上万,逐轮廓整幅 mask 会 OOM;过多则缩小重试 / Too many contours → downscale & retry + _max_contours_before_downscale: int = 6000 + _min_side_for_downscale: int = 400 + # 几何过滤:与噪点体积区分,减轻 Tetra3 假星导致的 TIMEOUT / Reject noise blobs vs point-like stars + _min_star_area: float = 2.0 + _max_star_area_frac: float = ( + 0.0035 # 单连通域面积不超过画幅比例 / Max contour area vs frame + ) + _min_circularity: float = 0.12 # 4πA/P²;细长热噪、条纹偏低 / Elongated junk is low + + def __init__(self, max_stars: int = 80) -> None: + self.max_stars = max_stars + + def extract(self, frame: np.ndarray) -> list[StarPoint]: + """提取星点 / Extract star points""" + if frame.ndim == 3: + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + else: + gray = frame.copy() + + h, w = gray.shape[:2] + side = max(h, w) + if side > self._max_input_side: + scale0 = self._max_input_side / float(side) + gray = cv2.resize( + gray, + (max(1, int(w * scale0)), max(1, int(h * scale0))), + interpolation=cv2.INTER_AREA, + ) + return self._extract_gray_scaled(gray, scale=1.0) + + def _extract_gray_scaled(self, gray: np.ndarray, scale: float) -> list[StarPoint]: + """在灰度图上提星;scale 为相对原图的坐标倍率 / Extract on gray; scale maps coords to original frame.""" + blur = cv2.GaussianBlur(gray, (3, 3), 0) + _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + # 去掉孤立椒盐点,减少伪轮廓 / Morph open removes salt noise, fewer false contours + _k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) + binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, _k) + + contours, _ = cv2.findContours( + binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + h, w = gray.shape[:2] + if ( + len(contours) > self._max_contours_before_downscale + and min(h, w) > self._min_side_for_downscale + ): + small = cv2.resize( + gray, + (max(1, w // 2), max(1, h // 2)), + interpolation=cv2.INTER_AREA, + ) + return self._extract_gray_scaled(small, scale * 2.0) + + # 已缩到最小仍极多轮廓时只保留面积最大的一批,避免 OOM / Cap contour count if still huge + if len(contours) > self._max_contours_before_downscale: + contours = heapq.nlargest( + self._max_contours_before_downscale, + contours, + key=cv2.contourArea, + ) + + frame_px = float(h * w) + max_area = self._max_star_area_frac * frame_px + + points: list[StarPoint] = [] + for contour in contours: + area = float(cv2.contourArea(contour)) + if area < self._min_star_area: + continue + if area > max_area: + continue + peri = float(cv2.arcLength(contour, True)) + if peri > 0.0: + circ = (4.0 * math.pi * area) / (peri * peri) + if circ < self._min_circularity: + continue + m = cv2.moments(contour) + if m["m00"] <= 0: + continue + cx = float(m["m10"] / m["m00"]) * scale + cy = float(m["m01"] / m["m00"]) * scale + mask = np.zeros_like(gray, dtype=np.uint8) + cv2.drawContours(mask, [contour], -1, color=255, thickness=-1) + flux = float(cv2.mean(gray, mask=mask)[0] * area) + points.append(StarPoint(x=cx, y=cy, flux=flux, area=area)) + + points.sort(key=lambda p: p.flux, reverse=True) + return points[: self.max_stars] diff --git a/ogscope/algorithms/star_match/__init__.py b/ogscope/algorithms/star_match/__init__.py new file mode 100644 index 0000000..23eaeb4 --- /dev/null +++ b/ogscope/algorithms/star_match/__init__.py @@ -0,0 +1,7 @@ +""" +星点匹配模块导出 / Star matching module exports +""" + +from ogscope.algorithms.star_match.tracker import FastTracker, TrackResult + +__all__ = ["FastTracker", "TrackResult"] diff --git a/ogscope/algorithms/star_match/tracker.py b/ogscope/algorithms/star_match/tracker.py new file mode 100644 index 0000000..5687027 --- /dev/null +++ b/ogscope/algorithms/star_match/tracker.py @@ -0,0 +1,61 @@ +""" +快速跟踪器 / Fast tracker +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import numpy as np + +from ogscope.algorithms.star_extract import StarPoint + + +@dataclass(slots=True) +class TrackResult: + """跟踪结果 / Tracking result""" + + delta_x: float + delta_y: float + matched_points: int + confidence: float + + def to_dict(self) -> dict[str, Any]: + return { + "delta_x": self.delta_x, + "delta_y": self.delta_y, + "matched_points": self.matched_points, + "confidence": self.confidence, + } + + +class FastTracker: + """基于质心偏移的轻量跟踪 / Lightweight tracking based on centroid shift""" + + def track(self, previous: list[StarPoint], current: list[StarPoint]) -> TrackResult: + """估计帧间位移 / Estimate inter-frame shift""" + if not previous or not current: + return TrackResult( + delta_x=0.0, delta_y=0.0, matched_points=0, confidence=0.0 + ) + + prev = np.array( + [[p.x, p.y, max(p.flux, 1e-6)] for p in previous], dtype=np.float64 + ) + cur = np.array( + [[p.x, p.y, max(p.flux, 1e-6)] for p in current], dtype=np.float64 + ) + prev_cx = float(np.average(prev[:, 0], weights=prev[:, 2])) + prev_cy = float(np.average(prev[:, 1], weights=prev[:, 2])) + cur_cx = float(np.average(cur[:, 0], weights=cur[:, 2])) + cur_cy = float(np.average(cur[:, 1], weights=cur[:, 2])) + + matched_points = min(len(previous), len(current)) + confidence = min(1.0, matched_points / 20.0) + return TrackResult( + delta_x=cur_cx - prev_cx, + delta_y=cur_cy - prev_cy, + matched_points=matched_points, + confidence=confidence, + ) diff --git a/ogscope/config.py b/ogscope/config.py index fb32568..502f3c7 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -1,6 +1,7 @@ """ 配置管理模块 """ + from functools import lru_cache from pathlib import Path from typing import Optional @@ -10,68 +11,265 @@ class Settings(BaseSettings): - """应用配置""" - - # 基础配置 + """应用配置 / Application configuration""" + + # 基础配置 / Basic configuration environment: str = Field(default="development", description="运行环境") debug: bool = Field(default=True, description="调试模式") - - # Web 服务配置 + + # Web 服务配置 / Web service configuration host: str = Field(default="0.0.0.0", description="Web 服务地址") port: int = Field(default=8000, description="Web 服务端口") reload: bool = Field(default=True, description="代码变更时自动重载") - - # 日志配置 + + # 日志配置 / Log configuration log_level: str = Field(default="INFO", description="日志级别") log_file: Optional[Path] = Field(default=None, description="日志文件路径") - - # 相机配置 + + # 相机配置 / Camera configuration camera_type: str = Field(default="imx327_mipi", description="相机类型: usb/csi/spi") - camera_width: int = Field(default=640, description="图像宽度") - camera_height: int = Field(default=360, description="图像高度") - camera_fps: int = Field(default=15, description="帧率") + camera_width: int = Field( + default=1600, description="图像宽度 / Default capture width" + ) + camera_height: int = Field( + default=900, description="图像高度 / Default capture height" + ) + camera_fps: int = Field( + default=5, description="预览与调试默认帧率 / Default preview FPS" + ) + camera_sampling_mode: str = Field( + default="native", description="采样模式: supersample/native/crop" + ) camera_exposure: int = Field(default=10000, description="曝光时间(us)") 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", + ) + camera_ae_exposure_value: float = Field( + default=0.35, + ge=-2.0, + le=2.0, + description="AE 曝光补偿(档),与 camera_ae_polar_preset 联用 / AE exposure comp EV stops", + ) + + # 显示屏配置 / Display configuration display_enabled: bool = Field(default=False, description="启用 SPI 屏幕") display_type: str = Field(default="st7789", description="显示屏类型") display_width: int = Field(default=240, description="屏幕宽度") display_height: int = Field(default=320, description="屏幕高度") display_rotation: int = Field(default=0, description="屏幕旋转角度") - - # 极轴校准配置 + + # 极轴校准配置 / Polar calibration configuration polar_align_timeout: int = Field(default=300, description="校准超时时间(秒)") polar_align_precision: float = Field(default=1.0, description="校准精度(角分)") - - # 数据库配置 + + # 数据库配置 / Database configuration database_url: str = Field( - default="sqlite:///./ogscope.db", - description="数据库连接字符串" + default="sqlite:///./ogscope.db", description="数据库连接字符串" ) - - # 文件路径配置 + + # 文件路径配置 / File path configuration data_dir: Path = Field(default=Path("./data"), description="数据目录") upload_dir: Path = Field(default=Path("./uploads"), description="上传目录") + analysis_dir: Path = Field( + default=Path("./data/analysis"), description="分析任务目录" + ) + plate_solve_dir: Path = Field( + default=Path("./data/plate_solve"), + description="Tetra3 图案库目录 / Tetra3 pattern database directory", + ) + solver_tetra_database_path: Optional[Path] = Field( + default=None, + description="default_database.npz 绝对路径;None 则使用 vendor 内 data/default_database.npz / Absolute path to default_database.npz", + ) + solver_fov_max_error_deg: Optional[float] = Field( + default=None, + description="FOV 估计允许误差(度);None 为库默认 / Max FOV estimate error in degrees", + ) + solver_timeout_ms: int = Field( + default=1500, + description="Tetra3 单次解算超时毫秒 / Tetra3 solve timeout in ms", + ) static_dir: Path = Field(default=Path("./web/static"), description="静态文件目录") template_dir: Path = Field(default=Path("./web/templates"), description="模板目录") - + + # 星图解算配置 / Plate solving configuration + 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)" + ) + solver_max_stars: int = Field(default=80, description="用于解算的最大星点数量") + solver_fullsolve_interval_frames: int = Field( + default=10, description="实时模式全量解算间隔帧数" + ) + # Tetra3 get_centroids_from_image 默认(可环境覆盖)/ Defaults for centroid extraction + solver_centroid_sigma: float = Field( + default=2.5, + description="σ 阈值倍数;略高可减少假星 / Sigma multiplier for thresholding", + ) + solver_centroid_max_area: int = Field( + default=400, + description="连通域最大像素面积;过小会丢掉亮星光晕 / Max spot area in pixels", + ) + solver_centroid_min_area: int = Field( + default=5, + description="连通域最小像素面积 / Min spot area in pixels", + ) + solver_centroid_filtsize: int = Field( + default=25, + description="局部背景/噪声滤波边长,须为奇数 / Local filter size (odd)", + ) + solver_centroid_binary_open: bool = Field( + default=True, + description="二值开运算去噪 / Binary opening on threshold mask", + ) + solver_centroid_bg_sub_mode: str = Field( + default="local_mean", + description="背景扣除模式 / Background subtraction mode (Tetra3)", + ) + solver_centroid_sigma_mode: str = Field( + default="global_root_square", + description="噪声 σ 估计模式 / Noise sigma mode (Tetra3)", + ) + solver_centroid_max_axis_ratio: Optional[float] = Field( + default=None, + description="长细比上限;None 为不限制 / Max major/minor axis ratio, None to disable", + ) + solver_max_image_side: int = Field( + default=1600, + description="提星前长边上限(像素),与默认采集长边对齐 / Max long side before extraction", + ) + solver_large_scale_bg_downsample: int = Field( + default=256, + ge=32, + le=2048, + description="大尺度背景减除:小图长边上限(像素),越小越快 / Large-scale BG downsample max side", + ) + star_analysis_target_fps: float = Field( + default=2 / 3, + description="星空分析目标帧率(约 1.5 秒 1 帧),仅用于前端节流 / Target star-analysis FPS for UI throttle (~1.5s per frame)", + ) + star_analysis_min_interval_ms: int = Field( + default=2000, + ge=500, + le=30000, + description="实时解算最小间隔(毫秒)/ Minimum interval for realtime solving in ms", + ) + star_analysis_max_interval_ms: int = Field( + default=12000, + ge=1000, + le=60000, + description="实时解算最大间隔(毫秒)/ Maximum interval for realtime solving in ms", + ) + star_analysis_request_timeout_ms: int = Field( + default=4500, + ge=500, + le=120000, + description="实时解算请求外层硬超时(毫秒)/ Outer hard timeout for realtime solve request in ms", + ) + star_analysis_slow_threshold_ms: int = Field( + default=3000, + ge=200, + le=120000, + description="实时解算慢请求阈值(毫秒)/ Slow realtime solve threshold in ms", + ) + + # WiFi(nmcli + scripts/ogscope-wifi-switch.sh)/ WiFi (NetworkManager helper script) + wifi_switch_script: Path = Field( + default=Path("/usr/local/bin/ogscope-wifi-switch"), + description="WiFi 切换脚本路径 / Path to ogscope-wifi-switch script", + ) + wifi_switch_use_sudo: bool = Field( + default=True, + description="调用脚本时是否使用 sudo -n / sudo -n when invoking script", + ) + wifi_switch_timeout_seconds: int = Field( + default=90, + ge=10, + le=600, + description="nmcli 切换超时(秒)/ Timeout for nmcli switch", + ) + wifi_nmcli_use_sudo: bool = Field( + default=True, + description=( + "非 root 时对 nmcli 使用 sudo -n(需 sudoers 放行 nmcli;" + "否则 polkit 会拒绝 connection up)/ sudo -n for nmcli when not root" + ), + ) + wifi_sta_connection: str = Field( + default="", + description="STA 模式 NM 连接名(空则禁用 WiFi API)/ STA connection name (empty disables API)", + ) + wifi_ap_connection: str = Field( + default="", + description="AP 模式 NM 连接名 / AP connection name", + ) + wifi_interface: str = Field( + default="wlan0", + description="无线接口名 / Wireless interface name", + ) + wifi_ap_url_host: str = Field( + default="192.168.4.1", + description="AP 模式下前端提示用的主机地址(不含端口)/ AP URL hint host without port", + ) + wifi_emergency_gpio_enabled: bool = Field( + default=False, + description="启用短接 GPIO 强制切 STA / Enable GPIO short-to-STA recovery", + ) + wifi_emergency_pin_out_bcm: int = Field( + default=22, + description="应急检测:输出低电平(BCM)/ Emergency: output LOW (BCM)", + ) + wifi_emergency_pin_in_bcm: int = Field( + default=23, + description="应急检测:上拉输入(BCM)/ Emergency: input with pull-up (BCM)", + ) + wifi_emergency_hold_seconds: float = Field( + default=2.0, + ge=0.5, + le=30.0, + description="短接持续多久触发 STA / Hold time before forcing STA", + ) + device_id_suffix: str = Field( + default="", + description="设备后缀(network.env 中 OGSCOPE_DEVICE_ID_SUFFIX)/ Device id suffix from network.env", + ) + wifi_ap_ssid: str = Field( + default="", + description="AP 的 SSID(可选,来自 network.env)/ AP SSID from network.env", + ) + wifi_sta_rollback_timeout_seconds: int = Field( + default=90, + ge=20, + le=600, + description="切 STA 后无可用 IPv4 则回滚 AP 的超时(秒)/ Roll back to AP if no IPv4", + ) + wifi_sta_rollback_interval_seconds: int = Field( + default=5, + ge=2, + le=60, + description="STA 连通性轮询间隔(秒)/ Poll interval for STA rollback check", + ) + model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="OGSCOPE_", case_sensitive=False, ) - + def __init__(self, **kwargs): super().__init__(**kwargs) - # 创建必要的目录 + # 创建必要的目录 / Create necessary directories self.data_dir.mkdir(parents=True, exist_ok=True) self.upload_dir.mkdir(parents=True, exist_ok=True) + self.analysis_dir.mkdir(parents=True, exist_ok=True) + self.plate_solve_dir.mkdir(parents=True, exist_ok=True) -@lru_cache() +@lru_cache def get_settings() -> Settings: - """获取配置单例""" + """获取配置单例 / Get configuration singleton""" return Settings() - diff --git a/ogscope/core/realtime/__init__.py b/ogscope/core/realtime/__init__.py new file mode 100644 index 0000000..91d447f --- /dev/null +++ b/ogscope/core/realtime/__init__.py @@ -0,0 +1,7 @@ +""" +实时解算模块导出 / Realtime solving exports +""" + +from ogscope.core.realtime.service import RealtimeSolveService, realtime_solve_service + +__all__ = ["RealtimeSolveService", "realtime_solve_service"] diff --git a/ogscope/core/realtime/service.py b/ogscope/core/realtime/service.py new file mode 100644 index 0000000..033f27e --- /dev/null +++ b/ogscope/core/realtime/service.py @@ -0,0 +1,161 @@ +""" +实时解算服务 / Realtime solving service +""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +from ogscope.algorithms.plate_solve import PlateSolver, SolveResult +from ogscope.algorithms.star_extract import StarExtractor, StarPoint +from ogscope.config import get_settings +from ogscope.web.camera_shared import get_camera_manager + + +@dataclass(slots=True) +class RealtimeState: + """实时状态 / Realtime state""" + + running: bool = False + frame_count: int = 0 + fullsolve_count: int = 0 + last_result: dict[str, Any] | None = None + last_error: str = "" + + +class RealtimeSolveService: + """实时解算器:周期性 Tetra3 全量解算 / Realtime solver with periodic Tetra3""" + + def __init__(self) -> None: + settings = get_settings() + self.extractor = StarExtractor(max_stars=settings.solver_max_stars) + self.solver = PlateSolver( + fov_deg=settings.solver_fov_deg, + fov_max_error_deg=settings.solver_fov_max_error_deg, + solve_timeout_ms=settings.solver_timeout_ms, + ) + self.state = RealtimeState() + self._task: asyncio.Task[None] | None = None + self._previous_stars: list[StarPoint] | None = None + 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) + self._fov_estimate: float | None = None + self._fov_max_error: float | None = None + self._solve_timeout_ms: int | None = None + + async def start( + self, + hint_ra_deg: float | None = None, + hint_dec_deg: float | None = None, + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + ) -> dict[str, Any]: + """启动实时解算 / Start realtime solving""" + if self.state.running: + return { + "success": True, + "message": "实时解算已在运行 / Realtime solver already running", + } + if hint_ra_deg is not None: + self._hint_ra = hint_ra_deg + if hint_dec_deg is not None: + self._hint_dec = hint_dec_deg + self._fov_estimate = fov_estimate + self._fov_max_error = fov_max_error + self._solve_timeout_ms = solve_timeout_ms + self.state = RealtimeState(running=True) + self._previous_stars = None + self._task = asyncio.create_task(self._loop()) + return {"success": True, "message": "实时解算已启动 / Realtime solver started"} + + async def stop(self) -> dict[str, Any]: + """停止实时解算 / Stop realtime solving""" + self.state.running = False + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + return {"success": True, "message": "实时解算已停止 / Realtime solver stopped"} + + async def get_status(self) -> dict[str, Any]: + """读取实时状态 / Read realtime status""" + return { + "running": self.state.running, + "frame_count": self.state.frame_count, + "fullsolve_count": self.state.fullsolve_count, + "last_result": self.state.last_result, + "last_error": self.state.last_error, + } + + async def _loop(self) -> None: + """后台循环 / Background loop""" + while self.state.running: + try: + manager = get_camera_manager() + cam = manager.get_camera_instance() + if not cam or not getattr(cam, "is_capturing", False): + await asyncio.sleep(0.1) + continue + # 必须与共享预览走同一套读锁 + 线程卸载,禁止在事件循环线程里直接 capture_array + # Must share the same read lock as shared preview; never call capture_array on the event-loop thread. + try: + frame, _fid, _ts = await manager.get_raw_frame() + except RuntimeError: + await asyncio.sleep(0.02) + continue + if frame is None: + await asyncio.sleep(0.02) + continue + 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 + ) + if use_fullsolve: + 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 + await asyncio.sleep(0.02) + except Exception as exc: # noqa: BLE001 + self.state.last_error = str(exc) + await asyncio.sleep(0.1) + + 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, + hint_ra_deg=self._hint_ra, + hint_dec_deg=self._hint_dec, + solve_source="realtime", + fov_estimate=self._fov_estimate, + fov_max_error=self._fov_max_error, + solve_timeout_ms=self._solve_timeout_ms, + ) + + def _apply_solve_result(self, solved: SolveResult) -> None: + """写入解算结果 / Persist solve result""" + self.state.last_result = solved.to_dict() + self._hint_ra = solved.ra_deg + self._hint_dec = solved.dec_deg + + +realtime_solve_service = RealtimeSolveService() diff --git a/ogscope/hardware/camera.py b/ogscope/hardware/camera.py index 2ed3d06..419a053 100644 --- a/ogscope/hardware/camera.py +++ b/ogscope/hardware/camera.py @@ -1,171 +1,450 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ 相机驱动模块 支持 Raspberry Pi Zero 2W 的 MIPI CSI 接口 IMX327 相机 """ import logging -from typing import Optional, Tuple, Dict, Any -import numpy as np from abc import ABC, abstractmethod +from typing import Any, Optional + +import numpy as np logger = logging.getLogger(__name__) class CameraInterface(ABC): - """相机接口抽象类""" - + """相机接口抽象类 / Camera interface abstract class""" + @abstractmethod def initialize(self) -> bool: - """初始化相机""" + """初始化相机 / Initialize camera""" pass - + @abstractmethod def start_capture(self) -> bool: - """开始图像捕获""" + """开始图像捕获 / Start image capture""" pass - + @abstractmethod def stop_capture(self) -> bool: - """停止图像捕获""" + """停止图像捕获 / Stop image capture""" pass - + @abstractmethod def capture_image(self) -> Optional[np.ndarray]: - """捕获单张图像""" + """捕获单张图像 / Capture a single image""" pass - + @abstractmethod def set_exposure(self, exposure_us: int) -> bool: - """设置曝光时间""" + """设置曝光时间 / Set exposure time""" pass - + @abstractmethod def set_gain(self, analogue_gain: float, digital_gain: float = 1.0) -> bool: - """设置增益""" + """设置增益 / Set gain""" pass - + @abstractmethod - def get_camera_info(self) -> Dict[str, Any]: - """获取相机信息""" + def get_camera_info(self) -> dict[str, Any]: + """获取相机信息 / Get camera information""" pass class IMX327MIPICamera(CameraInterface): - """IMX327 MIPI 相机驱动 - 基于 Picamera2""" - - def __init__(self, config: Dict[str, Any]): + """IMX327 MIPI 相机驱动 - 基于 Picamera2 / IMX327 MIPI camera driver - based on Picamera2""" + + SENSOR_MAX_WIDTH = 1920 + SENSOR_MAX_HEIGHT = 1020 + PREVIEW_BUFFER_COUNT = 2 + 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}, + "DigitalGain": {"min": 1.0, "max": 4.0, "default": 1.0, "step": 0.1}, + } + + def __init__(self, config: dict[str, Any]): self.config = config self.camera = None self.is_initialized = False self.is_capturing = False - - # 相机参数 - self.width = config.get('width', 640) - self.height = config.get('height', 360) - self.fps = config.get('fps', 5) - self.exposure_us = config.get('exposure_us', 10000) - self.analogue_gain = config.get('analogue_gain', 1.0) - self.digital_gain = config.get('digital_gain', 1.0) - self.auto_exposure = config.get('auto_exposure', False) - self.auto_gain = config.get('auto_gain', False) - self.rotation = config.get('rotation', 0) - # 采样模式与尺寸(supersample: 采集分辨率可高于输出分辨率) - self.sampling_mode = config.get('sampling_mode', 'supersample') # supersample | native | crop - - # 根据采样模式设置捕获和输出分辨率 - if self.sampling_mode == 'supersample': - # 超采样模式:设置更高的捕获分辨率,输出分辨率为配置的分辨率 - self.output_width = self.width - self.output_height = self.height - # 设置捕获分辨率为更高的分辨率(推荐2x超采样) - self.capture_width = max(self.width * 2, 1280) # 至少1280宽度 - self.capture_height = max(self.height * 2, 720) # 至少720高度 - # 确保捕获分辨率为16:9比例 - self._adjust_capture_resolution_to_aspect_ratio() + + # 相机参数 / Camera parameters + requested_width = int(config.get("width", 640)) + requested_height = int(config.get("height", 360)) + self.width, self.height = self._sanitize_output_resolution( + requested_width, requested_height + ) + self.fps = config.get("fps", 5) + self.exposure_us = config.get("exposure_us", 10000) + self.analogue_gain = config.get("analogue_gain", 1.0) + self.digital_gain = config.get("digital_gain", 1.0) + self.auto_exposure = config.get("auto_exposure", False) + self.auto_gain = config.get("auto_gain", False) + self.rotation = config.get("rotation", 0) + self.color_mode = config.get("color_mode", "color") # 'color' | 'mono' + self.white_balance_mode = config.get("white_balance_mode", "auto") + self.white_balance_gain_r = config.get("white_balance_gain_r", 1.0) + self.white_balance_gain_b = config.get("white_balance_gain_b", 1.0) + # 采样模式与尺寸(supersample: 采集分辨率可高于输出分辨率) / Sampling mode and size (supersample: acquisition resolution can be higher than output resolution) + self.sampling_mode = config.get( + "sampling_mode", "native" + ) # supersample | native | crop + ( + self.sampling_mode, + self.capture_width, + self.capture_height, + self.output_width, + self.output_height, + ) = self._resolve_sampling_layout(self.sampling_mode, self.width, self.height) + + # 电子极轴镜默认 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)) + + logger.info( + f"初始化 IMX327 MIPI 相机: {self.width}x{self.height}@{self.fps}fps" + ) + + @staticmethod + def _to_number(value: Any) -> Optional[float]: + try: + if value is None: + return None + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + return float(value) + return float(value) + except (TypeError, ValueError): + return None + + def _parse_control_descriptor(self, descriptor: Any) -> dict[str, float]: + parsed: dict[str, float] = {} + + if isinstance(descriptor, dict): + for key in ("min", "max", "default", "step"): + numeric_value = self._to_number(descriptor.get(key)) + if numeric_value is not None: + parsed[key] = numeric_value + return parsed + + if isinstance(descriptor, (tuple, list)): + if len(descriptor) >= 1: + min_value = self._to_number(descriptor[0]) + if min_value is not None: + parsed["min"] = min_value + if len(descriptor) >= 2: + max_value = self._to_number(descriptor[1]) + if max_value is not None: + parsed["max"] = max_value + if len(descriptor) >= 3: + default_value = self._to_number(descriptor[2]) + if default_value is not None: + parsed["default"] = default_value + if len(descriptor) >= 4: + step_value = self._to_number(descriptor[3]) + if step_value is not None: + parsed["step"] = step_value + return parsed + + for target_key, attr_names in { + "min": ("min", "minimum", "lower", "lower_bound"), + "max": ("max", "maximum", "upper", "upper_bound"), + "default": ("default",), + "step": ("step", "increment"), + }.items(): + for attr_name in attr_names: + raw_value = getattr(descriptor, attr_name, None) + numeric_value = self._to_number(raw_value) + if numeric_value is not None: + parsed[target_key] = numeric_value + break + + return parsed + + def _extract_control_range( + self, control_name: str, default_range: dict[str, float] + ) -> dict[str, float]: + result = dict(default_range) + if not self.camera: + return result + + controls = getattr(self.camera, "camera_controls", None) or {} + descriptor = controls.get(control_name) + if descriptor is None: + return result + + parsed = self._parse_control_descriptor(descriptor) + for key in ("min", "max", "default", "step"): + if key in parsed: + result[key] = parsed[key] + + min_value = result.get("min") + max_value = result.get("max") + if ( + isinstance(min_value, (int, float)) + and isinstance(max_value, (int, float)) + and min_value > max_value + ): + result["min"], result["max"] = max_value, min_value + + return result + + def get_manual_control_ranges(self) -> dict[str, dict[str, Any]]: + exposure = self._extract_control_range( + "ExposureTime", self.MANUAL_CONTROL_RANGE_DEFAULTS["ExposureTime"] + ) + analogue = self._extract_control_range( + "AnalogueGain", self.MANUAL_CONTROL_RANGE_DEFAULTS["AnalogueGain"] + ) + digital = self._extract_control_range( + "DigitalGain", self.MANUAL_CONTROL_RANGE_DEFAULTS["DigitalGain"] + ) + return { + "exposure_us": { + "min": int(round(exposure["min"])), + "max": int(round(exposure["max"])), + "default": int(round(exposure["default"])), + "step": max(1, int(round(exposure.get("step", 1)))), + }, + "analogue_gain": { + "min": float(analogue["min"]), + "max": float(analogue["max"]), + "default": float(analogue["default"]), + "step": float(analogue.get("step", 0.1)), + }, + "digital_gain": { + "min": float(digital["min"]), + "max": float(digital["max"]), + "default": float(digital["default"]), + "step": float(digital.get("step", 0.1)), + "supported": "DigitalGain" + in (getattr(self.camera, "camera_controls", {}) or {}), + }, + } + + @staticmethod + def _align_even(value: int) -> int: + value = max(2, int(value)) + return value if value % 2 == 0 else value - 1 + + def _sanitize_output_resolution(self, width: int, height: int) -> tuple[int, int]: + safe_w = min(self.SENSOR_MAX_WIDTH, max(160, int(width))) + safe_h = min(self.SENSOR_MAX_HEIGHT, max(120, int(height))) + safe_w = self._align_even(safe_w) + safe_h = self._align_even(safe_h) + if (safe_w, safe_h) != (int(width), int(height)): + logger.warning( + f"请求分辨率 {width}x{height} 超出 IMX327 安全范围,已调整为 {safe_w}x{safe_h}" + ) + return safe_w, safe_h + + def _resolve_sampling_layout( + self, mode: str, output_width: int, output_height: int + ) -> tuple[str, int, int, int, int]: + output_width, output_height = self._sanitize_output_resolution( + output_width, output_height + ) + if mode not in {"supersample", "native", "crop"}: + mode = "native" + if mode == "supersample": + capture_w = self.SENSOR_MAX_WIDTH + capture_h = self.SENSOR_MAX_HEIGHT else: - # native/crop模式:捕获和输出分辨率相同 - self.capture_width = self.width - self.capture_height = self.height - self.output_width = self.width - self.output_height = self.height - - logger.info(f"初始化 IMX327 MIPI 相机: {self.width}x{self.height}@{self.fps}fps") - - def _adjust_capture_resolution_to_aspect_ratio(self): - """调整捕获分辨率为16:9比例""" - target_aspect_ratio = 16.0 / 9.0 - current_aspect_ratio = self.capture_width / self.capture_height - - if abs(current_aspect_ratio - target_aspect_ratio) > 0.01: # 允许小的误差 - # 以宽度为准,调整高度 - self.capture_height = int(self.capture_width / target_aspect_ratio) - # 确保高度是偶数(某些相机要求) - if self.capture_height % 2 != 0: - self.capture_height += 1 - + # 关闭超采样时按输出尺寸采集,减少大帧常驻与重采样开销 + # Capture at output size when supersample is off to reduce RAM and resize cost. + capture_w = output_width + capture_h = output_height + if mode == "supersample" and ( + output_width >= self.SENSOR_MAX_WIDTH + or output_height >= self.SENSOR_MAX_HEIGHT + ): + logger.warning("当前分辨率下超采样无有效增益,自动切换为 native 模式") + mode = "native" + capture_w = output_width + capture_h = output_height + return mode, capture_w, capture_h, output_width, output_height + + def _resize_preserve_fov( + self, image: np.ndarray, target_width: int, target_height: int + ) -> np.ndarray: + import cv2 + + src_h, src_w = image.shape[:2] + if src_w == target_width and src_h == target_height: + return image + + scale = min(target_width / src_w, target_height / src_h) + resized_w = max(1, int(round(src_w * scale))) + resized_h = max(1, int(round(src_h * scale))) + interpolation = cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR + resized = cv2.resize(image, (resized_w, resized_h), interpolation=interpolation) + + if resized_w == target_width and resized_h == target_height: + return resized + + pad_x = max(0, target_width - resized_w) + pad_y = max(0, target_height - resized_h) + left = pad_x // 2 + right = pad_x - left + top = pad_y // 2 + bottom = pad_y - top + border_value = (0, 0, 0) if len(resized.shape) == 3 else 0 + return cv2.copyMakeBorder( + resized, + top, + bottom, + left, + right, + cv2.BORDER_CONSTANT, + value=border_value, + ) + + def _apply_polar_auto_exposure_controls(self) -> None: + """libcamera AE 预设:暗部优先、矩阵测光、偏长曝光、EV;失败项跳过 / AE preset; skip unsupported controls.""" + if not self.camera or not self.auto_exposure: + return + if not self.ae_polar_preset: + try: + self.camera.set_controls({"AeEnable": True}) + 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)) + + try: + self.camera.set_controls(updates) + logger.info( + "已应用电子极轴镜 AE 预设 (Shadows/Matrix/Long, EV≈%.2f)", + ev, + ) + except Exception as e: + logger.warning("AE 预设批量设置失败,逐项重试: %s", e) + for key, val in updates.items(): + if key != "AeEnable" and key not in cc: + continue + try: + self.camera.set_controls({key: val}) + except Exception as err: + logger.debug("AE 控制 %s 未生效: %s", key, err) + def initialize(self) -> bool: - """初始化 MIPI 相机""" + """初始化 MIPI 相机 / Initialize MIPI camera""" try: from picamera2 import Picamera2 - + self.camera = Picamera2() - - # 配置相机 - camera_config = self.camera.create_still_configuration( - main={"size": (self.capture_width, self.capture_height), "format": "RGB888"}, - raw={"size": (self.capture_width, self.capture_height), "format": "SRGGB12"} + + # 统一使用RGB888格式,颜色模式转换在图像处理阶段进行 / RGB888 format is uniformly used, and color mode conversion is performed in the image processing stage. + # 这样可以保持相机配置的一致性,避免格式兼容性问题 / This maintains consistency in camera configuration and avoids format compatibility issues + main_format = "RGB888" + + # 配置相机 / Configure camera + camera_config = self.camera.create_video_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": main_format, + }, + buffer_count=self.PREVIEW_BUFFER_COUNT, ) - + self.camera.configure(camera_config) - - # 设置相机控制参数 - # 构建控制参数,兼容部分固件未提供 DigitalGain 的情况 + + # 设置相机控制参数 / 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, - "AwbEnable": False, # 禁用自动白平衡 - "NoiseReductionMode": 0, # 禁用降噪以获得原始数据 + "AwbEnable": False, # 禁用自动白平衡 / Disable automatic white balance + "NoiseReductionMode": 0, # 禁用降噪以获得原始数据 / Disable noise reduction to get raw data } try: self.camera.set_controls({**controls, "DigitalGain": self.digital_gain}) except Exception: - # DigitalGain 不被支持时,退化为不设置该项 + # DigitalGain 不被支持时,退化为不设置该项 / When DigitalGain is not supported, it will degenerate to not setting this item. self.camera.set_controls(controls) - + + if self.auto_exposure: + self._apply_polar_auto_exposure_controls() + self.is_initialized = True logger.info("IMX327 MIPI 相机初始化成功") return True - + except ImportError: - logger.error("Picamera2 库未安装,请运行: sudo apt install python3-picamera2") + logger.error( + "Picamera2 库未安装,请运行: sudo apt install python3-picamera2" + ) return False except Exception as e: logger.error(f"相机初始化失败: {e}") return False - + def start_capture(self) -> bool: - """开始图像捕获""" + """开始图像捕获 / Start image capture""" if not self.is_initialized: logger.error("相机未初始化") return False - + try: - # 使用视频配置以获得更高实时性 + # 使用视频配置以获得更高实时性 / Use video configuration for greater real-time performance try: video_config = self.camera.create_video_configuration( - main={"size": (self.capture_width, self.capture_height), "format": "RGB888"} + main={ + "size": (self.capture_width, self.capture_height), + "format": "RGB888", + }, + buffer_count=self.PREVIEW_BUFFER_COUNT, ) self.camera.configure(video_config) except Exception as e: logger.warning(f"视频配置失败,回退到当前配置: {e}") - # 设置目标帧率(若固件支持) + # 设置目标帧率(若固件支持) / Set target frame rate (if supported by firmware) try: self.camera.set_controls({"FrameRate": self.fps}) except Exception: pass + + # 重新配置后重放曝光控制,避免状态漂移到驱动默认值 / Replay exposure control after reconfiguration to avoid state drift to driver defaults + try: + if self.auto_exposure: + self._apply_polar_auto_exposure_controls() + else: + controls = { + "AeEnable": False, + "ExposureTime": self.exposure_us, + "AnalogueGain": self.analogue_gain, + } + try: + self.camera.set_controls( + {**controls, "DigitalGain": self.digital_gain} + ) + except Exception: + self.camera.set_controls(controls) + except Exception as e: + logger.warning(f"重放曝光控制失败,使用驱动默认控制: {e}") + self.camera.start() self.is_capturing = True logger.info("相机开始捕获") @@ -173,12 +452,12 @@ def start_capture(self) -> bool: except Exception as e: logger.error(f"启动相机失败: {e}") return False - + def stop_capture(self) -> bool: - """停止图像捕获""" + """停止图像捕获 / Stop image capture""" if not self.is_capturing: return True - + try: self.camera.stop() self.is_capturing = False @@ -187,52 +466,67 @@ def stop_capture(self) -> bool: except Exception as e: logger.error(f"停止相机失败: {e}") return False - + def capture_image(self) -> Optional[np.ndarray]: - """捕获单张图像""" + """捕获单张图像 / Capture a single image""" if not self.is_initialized: logger.error("相机未初始化") return None - + if not self.is_capturing: logger.error("相机未在捕获状态") return None - + try: - # 捕获图像 + # 捕获图像 / capture image image = self.camera.capture_array() - - # 如果是 RAW 格式,需要转换为 RGB - if len(image.shape) == 2: # RAW 格式 - # 这里需要实现 RAW 到 RGB 的转换 - # 暂时返回原始数据 + + # 如果是 RAW 格式,需要转换为 RGB / If it is RAW format, it needs to be converted to RGB + if len(image.shape) == 2: # RAW 格式 / RAW format + # 这里需要实现 RAW 到 RGB 的转换 / Here you need to implement RAW to RGB conversion + # 暂时返回原始数据 / Temporarily return to original data pass - - # 软件降采样(超采样模式) + + # 输出重采样(仅当采集与输出不一致) / Output resampling only when capture/output differ try: - if self.sampling_mode == 'supersample': - if (self.output_width, self.output_height) != (self.capture_width, self.capture_height): - import cv2 - original_shape = image.shape[:2] - image = cv2.resize(image, (self.output_width, self.output_height), interpolation=cv2.INTER_AREA) - logger.debug(f"超采样降采样: {original_shape[1]}x{original_shape[0]} -> {self.output_width}x{self.output_height}") - else: - logger.debug("超采样模式但输出尺寸与捕获尺寸相同,跳过降采样") + if (self.output_width, self.output_height) != ( + image.shape[1], + image.shape[0], + ): + original_shape = image.shape[:2] + image = self._resize_preserve_fov( + image, + self.output_width, + self.output_height, + ) + logger.debug( + f"输出重采样: {original_shape[1]}x{original_shape[0]} -> {self.output_width}x{self.output_height}" + ) except Exception as e: - logger.warning(f"降采样失败(忽略,使用原图): {e}") + logger.warning(f"输出重采样失败(忽略,使用原图): {e}") - # 应用旋转 + # 应用旋转 / Apply rotation if self.rotation != 0: image = self.apply_rotation(image, self.rotation) - + + # 应用颜色模式转换 / Apply color mode conversion + if self.color_mode == "mono" and len(image.shape) == 3: + # 将彩色图像转换为灰度 / Convert color image to grayscale + import cv2 + + gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) + # 转换为3通道灰度图像(保持兼容性) / Convert to 3-channel grayscale image (maintain compatibility) + image = cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) + logger.debug("应用黑白模式转换") + return image - + except Exception as e: logger.error(f"捕获图像失败: {e}") return None def apply_rotation(self, image: np.ndarray, rotation: int) -> np.ndarray: - """应用图像旋转""" + """应用图像旋转 / Apply image rotation""" try: if rotation == 90: return np.rot90(image, 1) @@ -247,85 +541,87 @@ def apply_rotation(self, image: np.ndarray, rotation: int) -> np.ndarray: return image def get_video_frame(self) -> Optional[np.ndarray]: - """获取一帧视频图像(用于实时流)""" + """获取一帧视频图像(用于实时流) / Get a frame of video image (for live streaming)""" if not self.is_initialized: logger.error("相机未初始化") return None return self.capture_image() - def set_resolution(self, width: int, height: int, fps: Optional[int] = None) -> bool: - """运行时切换分辨率/帧率;在 supersample 下优先仅调整输出尺寸,必要时才重配硬件""" + def set_resolution( + self, width: int, height: int, fps: Optional[int] = None + ) -> bool: + """运行时切换分辨率 / Switch resolution at runtime""" if not self.is_initialized: logger.error("相机未初始化") return False - - was_capturing = self.is_capturing - try: - new_w, new_h = int(width), int(height) - if fps is not None: - self.fps = int(fps) - - if self.sampling_mode == 'supersample': - # 超采样模式:只更新输出分辨率 - self.output_width = new_w - self.output_height = new_h - self.width = new_w - self.height = new_h - - # 检查是否需要提升捕获分辨率 - target_capture_width = max(new_w * 2, 1280) - target_capture_height = max(new_h * 2, 720) - need_reconfig = (target_capture_width > self.capture_width) or (target_capture_height > self.capture_height) - if not need_reconfig: - # 不需要重新配置硬件,只需要更新帧率 - try: - self.camera.set_controls({"FrameRate": self.fps}) - except Exception: - pass - return True - else: - # 需要提升捕获分辨率 - if was_capturing: - if not self.stop_capture(): - return False - # 设置更高的捕获分辨率(至少2x超采样) - self.capture_width = target_capture_width - self.capture_height = target_capture_height - self._adjust_capture_resolution_to_aspect_ratio() - else: - # native/crop模式:捕获和输出分辨率相同 - if was_capturing: - if not self.stop_capture(): - return False - self.capture_width = new_w - self.capture_height = new_h - self.output_width = new_w - self.output_height = new_h - self.width = new_w - self.height = new_h + new_w, new_h = self._sanitize_output_resolution(int(width), int(height)) + if fps is not None: + self.fps = int(fps) + + effective_mode, new_capture_w, new_capture_h, new_output_w, new_output_h = ( + self._resolve_sampling_layout(self.sampling_mode, new_w, new_h) + ) + if ( + self.output_width == new_output_w + and self.output_height == new_output_h + and self.capture_width == new_capture_w + and self.capture_height == new_capture_h + and self.sampling_mode == effective_mode + ): try: - video_config = self.camera.create_video_configuration( - main={"size": (self.capture_width, self.capture_height), "format": "RGB888"} - ) - self.camera.configure(video_config) + self.camera.set_controls({"FrameRate": self.fps}) except Exception: - still_cfg = self.camera.create_still_configuration( - main={"size": (self.capture_width, self.capture_height), "format": "RGB888"}, - raw={"size": (self.capture_width, self.capture_height), "format": "SRGGB12"} - ) - self.camera.configure(still_cfg) + pass + return True + + old_capture_w = self.capture_width + old_capture_h = self.capture_height + was_capturing = self.is_capturing + try: + self.sampling_mode = effective_mode + self.capture_width = new_capture_w + self.capture_height = new_capture_h + self.output_width = new_output_w + self.output_height = new_output_h + self.width = new_output_w + self.height = new_output_h + + need_reconfig = ( + old_capture_w != self.capture_width + or old_capture_h != self.capture_height + ) + if need_reconfig: + if was_capturing and not self.stop_capture(): + return False + try: + video_config = self.camera.create_video_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": "RGB888", + }, + buffer_count=self.PREVIEW_BUFFER_COUNT, + ) + self.camera.configure(video_config) + except Exception: + still_cfg = self.camera.create_still_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": "RGB888", + } + ) + self.camera.configure(still_cfg) try: self.camera.set_controls({"FrameRate": self.fps}) except Exception: pass - if was_capturing: + if need_reconfig and was_capturing: return self.start_capture() return True except Exception as e: logger.error(f"切换分辨率失败: {e}") - # 如果发生异常,尝试恢复到之前的状态 + # 如果发生异常,尝试恢复到之前的状态 / If an exception occurs, try to restore to the previous state if was_capturing and not self.is_capturing: try: self.start_capture() @@ -334,7 +630,7 @@ def set_resolution(self, width: int, height: int, fps: Optional[int] = None) -> return False def set_fps(self, fps: int) -> bool: - """仅设置帧率;若固件支持则动态生效,否则更新内部值备用""" + """仅设置帧率;若固件支持则动态生效,否则更新内部值备用 / Only set the frame rate; if the firmware supports it, it will take effect dynamically, otherwise the internal value will be updated for later use.""" if not self.is_initialized: logger.error("相机未初始化") return False @@ -343,64 +639,101 @@ def set_fps(self, fps: int) -> bool: try: self.camera.set_controls({"FrameRate": self.fps}) except Exception: - # 不支持动态设置时也返回 True,后续通过重配生效 + # 不支持动态设置时也返回 True,后续通过重配生效 / True is also returned when dynamic setting is not supported, and will take effect later through reconfiguration. pass logger.info(f"帧率设置为: {self.fps}fps") return True except Exception as e: logger.error(f"设置帧率失败: {e}") return False - + def set_exposure(self, exposure_us: int) -> bool: - """设置曝光时间""" + """设置曝光时间 / Set exposure time""" if not self.is_initialized: logger.error("相机未初始化") return False - + try: - self.camera.set_controls({"ExposureTime": exposure_us}) + self.camera.set_controls({"AeEnable": False, "ExposureTime": exposure_us}) self.exposure_us = exposure_us + self.auto_exposure = False logger.info(f"曝光时间设置为: {exposure_us}μs") return True except Exception as e: logger.error(f"设置曝光时间失败: {e}") return False - + def set_gain(self, analogue_gain: float, digital_gain: float = 1.0) -> bool: - """设置增益""" + """设置增益 / Set gain""" if not self.is_initialized: logger.error("相机未初始化") return False - + try: - # 优先同时设置,若不支持 DigitalGain 则退化仅设置 AnalogueGain + # 手动设置增益时显式关闭自动曝光,避免控制冲突 / Explicitly turn off automatic exposure when setting gain manually to avoid control conflicts try: - self.camera.set_controls({ - "AnalogueGain": analogue_gain, - "DigitalGain": digital_gain - }) + self.camera.set_controls({"AeEnable": False}) except Exception: - self.camera.set_controls({ - "AnalogueGain": analogue_gain - }) + pass + + # 优先同时设置,若不支持 DigitalGain 则退化仅设置 AnalogueGain / Priority is given to setting both at the same time. If DigitalGain is not supported, only AnalogueGain is set. + try: + self.camera.set_controls( + {"AnalogueGain": analogue_gain, "DigitalGain": digital_gain} + ) + except Exception: + self.camera.set_controls({"AnalogueGain": analogue_gain}) self.analogue_gain = analogue_gain self.digital_gain = digital_gain + self.auto_exposure = False logger.info(f"增益设置为: 模拟={analogue_gain}, 数字={digital_gain}") return True except Exception as e: logger.error(f"设置增益失败: {e}") return False - + + def set_auto_exposure(self, enabled: bool) -> bool: + """设置自动曝光开关 / Set the automatic exposure switch""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + + try: + self.auto_exposure = enabled + if enabled: + self._apply_polar_auto_exposure_controls() + else: + self.camera.set_controls({"AeEnable": False}) + + # 关闭自动曝光时,立即重放当前手动参数,确保状态一致 / When auto-exposure is turned off, the current manual parameters are immediately replayed to ensure consistent status. + if not enabled: + controls = { + "ExposureTime": self.exposure_us, + "AnalogueGain": self.analogue_gain, + } + try: + self.camera.set_controls( + {**controls, "DigitalGain": self.digital_gain} + ) + except Exception: + self.camera.set_controls(controls) + + logger.info(f"自动曝光已{'启用' if enabled else '关闭'}") + return True + except Exception as e: + logger.error(f"设置自动曝光失败: {e}") + return False + def set_rotation(self, rotation: int) -> bool: - """设置图像旋转角度""" + """设置图像旋转角度 / Set image rotation angle""" if not self.is_initialized: logger.error("相机未初始化") return False - + if rotation not in [0, 90, 180, 270]: logger.error(f"不支持的旋转角度: {rotation}") return False - + self.rotation = rotation logger.info(f"图像旋转角度设置为: {rotation}度") return True @@ -410,57 +743,77 @@ def set_sampling_mode(self, mode: str) -> bool: if not self.is_initialized: logger.error("相机未初始化") return False - - if mode not in ['supersample', 'native', 'crop']: + + if mode not in ["supersample", "native", "crop"]: logger.error(f"不支持的采样模式: {mode}") return False try: old_mode = self.sampling_mode - self.sampling_mode = mode - logger.info(f"采样模式从 {old_mode} 切换到: {mode}") - - if mode != 'supersample': - # native/crop 下输出与采集一致 - self.capture_width = self.width - self.capture_height = self.height - self.output_width = self.width - self.output_height = self.height - logger.info(f"非超采样模式,捕获和输出分辨率设置为: {self.output_width}x{self.output_height}") - else: - # 超采样模式:设置更高的捕获分辨率 - self.output_width = self.width - self.output_height = self.height - # 设置捕获分辨率为更高的分辨率 - self.capture_width = max(self.width * 2, 1280) - self.capture_height = max(self.height * 2, 720) - self._adjust_capture_resolution_to_aspect_ratio() - - # 记录详细配置信息 - logger.info(f"超采样模式激活:") - logger.info(f" - 捕获分辨率: {self.capture_width}x{self.capture_height}") - logger.info(f" - 输出分辨率: {self.output_width}x{self.output_height}") - if self.capture_width > self.output_width and self.capture_height > self.output_height: - ratio = min(self.capture_width / self.output_width, self.capture_height / self.output_height) - logger.info(f" - 超采样比例: {ratio:.2f}x") - if ratio >= 1.5: - logger.info(" - 超采样质量: 优秀") - elif ratio >= 1.2: - logger.info(" - 超采样质量: 良好") - else: - logger.warning(" - 超采样质量: 较低,建议调整分辨率") - else: - logger.warning(" - 警告: 捕获分辨率不高于输出分辨率,超采样效果有限") - + old_capture_w = self.capture_width + old_capture_h = self.capture_height + ( + effective_mode, + capture_w, + capture_h, + output_w, + output_h, + ) = self._resolve_sampling_layout(mode, self.width, self.height) + + self.sampling_mode = effective_mode + self.capture_width = capture_w + self.capture_height = capture_h + self.output_width = output_w + self.output_height = output_h + self.width = output_w + self.height = output_h + logger.info(f"采样模式从 {old_mode} 切换到: {self.sampling_mode}") + + need_reconfig = (old_capture_w != self.capture_width) or ( + old_capture_h != self.capture_height + ) + if not need_reconfig: + return True + + was_capturing = self.is_capturing + if was_capturing and not self.stop_capture(): + return False + + try: + video_config = self.camera.create_video_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": "RGB888", + }, + buffer_count=self.PREVIEW_BUFFER_COUNT, + ) + self.camera.configure(video_config) + except Exception: + still_cfg = self.camera.create_still_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": "RGB888", + } + ) + self.camera.configure(still_cfg) + + try: + self.camera.set_controls({"FrameRate": self.fps}) + except Exception: + pass + + if was_capturing: + return self.start_capture() + return True except Exception as e: logger.error(f"设置采样模式失败: {e}") return False - - def get_camera_info(self) -> Dict[str, Any]: - """获取相机信息""" + + def get_camera_info(self) -> dict[str, Any]: + """获取相机信息 / Get camera information""" if not self.is_initialized: return {} - + try: camera_properties = self.camera.camera_properties return { @@ -480,18 +833,287 @@ def get_camera_info(self) -> Dict[str, Any]: "capture_height": self.capture_height, "output_width": self.output_width, "output_height": self.output_height, + "color_mode": self.color_mode, + "white_balance_mode": self.white_balance_mode, + "white_balance_gain_r": self.white_balance_gain_r, + "white_balance_gain_b": self.white_balance_gain_b, + "ae_polar_preset": self.ae_polar_preset, + "ae_exposure_value": self.ae_exposure_value, + "control_ranges": self.get_manual_control_ranges(), } except Exception as e: logger.error(f"获取相机信息失败: {e}") return {} + def get_image_quality_metrics(self) -> dict[str, Any]: + """获取图像质量指标 / Get image quality metrics""" + if not self.is_initialized: + return { + "noise_level": 0.0, + "exposure_adequacy": 0.0, + "gain_level": 0.0, + "night_mode": False, + "recommended_adjustments": ["相机未初始化"], + "camera_params": {}, + } + + try: + # 计算增益水平(模拟增益 + 数字增益) / Calculate gain level (analog gain + digital gain) + gain_level = self.analogue_gain * self.digital_gain + + # 根据曝光时间判断夜间模式 / Determine night mode based on exposure time + night_mode = ( + self.exposure_us > 30000 + ) # 曝光时间超过30ms认为是夜间模式 / Exposure time longer than 30ms is considered night mode + + # 计算曝光充足度(基于曝光时间) / Calculate exposure adequacy (based on exposure time) + # 假设10ms为基准曝光时间 / Assume 10ms as the base exposure time + exposure_adequacy = min(1.0, self.exposure_us / 10000.0) + + # 计算噪点水平(基于增益和曝光时间) / Calculate noise level (based on gain and exposure time) + # 增益越高,噪点越多;曝光时间越长,噪点也越多 / The higher the gain, the more noise; the longer the exposure time, the more noise + noise_level = min( + 1.0, (gain_level - 1.0) * 0.1 + (self.exposure_us - 10000) / 100000.0 + ) + noise_level = max(0.0, noise_level) + + # 生成调整建议 / Generate adjustment suggestions + recommendations = [] + if noise_level > 0.7: + recommendations.append("噪点水平较高,建议降低增益或缩短曝光时间") + if exposure_adequacy < 0.5: + recommendations.append("曝光不足,建议增加曝光时间或提高增益") + if gain_level > 8.0: + recommendations.append("增益过高,建议降低增益以提高图像质量") + if not recommendations: + recommendations.append("图像质量良好,无需调整") + + return { + "noise_level": round(noise_level, 3), + "exposure_adequacy": round(exposure_adequacy, 3), + "gain_level": round(gain_level, 3), + "night_mode": night_mode, + "recommended_adjustments": recommendations, + "camera_params": { + "exposure_us": self.exposure_us, + "analogue_gain": self.analogue_gain, + "digital_gain": self.digital_gain, + "noise_reduction": getattr(self, "noise_reduction", 0), + "width": self.width, + "height": self.height, + "fps": self.fps, + "sampling_mode": self.sampling_mode, + }, + } + + except Exception as e: + logger.error(f"获取图像质量指标失败: {e}") + return { + "noise_level": 0.0, + "exposure_adequacy": 0.0, + "gain_level": 0.0, + "night_mode": False, + "recommended_adjustments": [f"获取质量指标失败: {str(e)}"], + "camera_params": {}, + } + + def set_noise_reduction(self, level: int) -> bool: + """设置降噪级别 (0-4) / Set noise reduction level (0-4)""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + + try: + # 将级别映射到相机控制参数 / Map levels to camera control parameters + noise_reduction_mode = min(max(level, 0), 4) + self.camera.set_controls({"NoiseReductionMode": noise_reduction_mode}) + logger.info(f"降噪级别设置为: {noise_reduction_mode}") + return True + except Exception as e: + logger.error(f"设置降噪级别失败: {e}") + return False + + def set_white_balance( + self, mode: str, gain_r: float = 1.0, gain_b: float = 1.0 + ) -> bool: + """设置白平衡模式 / Set white balance mode""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + + try: + if mode == "auto": + self.camera.set_controls({"AwbEnable": True}) + self.white_balance_mode = "auto" + logger.info("白平衡设置为自动模式") + elif mode == "manual": + self.camera.set_controls( + {"AwbEnable": False, "ColourGains": (gain_r, gain_b)} + ) + self.white_balance_mode = "manual" + self.white_balance_gain_r = gain_r + self.white_balance_gain_b = gain_b + logger.info(f"白平衡设置为手动模式: R={gain_r}, B={gain_b}") + elif mode == "night": + # 夜间模式:稍微偏暖色调 / Night mode: Slightly warmer tones + self.camera.set_controls( + {"AwbEnable": False, "ColourGains": (1.1, 0.9)} + ) + self.white_balance_mode = "night" + self.white_balance_gain_r = 1.1 + self.white_balance_gain_b = 0.9 + logger.info("白平衡设置为夜间模式") + else: + logger.error(f"不支持的白平衡模式: {mode}") + return False + + return True + except Exception as e: + logger.error(f"设置白平衡失败: {e}") + return False + + def set_image_enhancement( + self, + contrast: float = 1.0, + brightness: float = 0.0, + saturation: float = 1.0, + sharpness: float = 1.0, + ) -> bool: + """设置图像增强参数 / Set image enhancement parameters""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + + try: + # 构建增强参数 / Build enhancement parameters + enhancement_controls = {} + + # 对比度 (0.5-2.0) / Contrast (0.5-2.0) + if 0.5 <= contrast <= 2.0: + enhancement_controls["Contrast"] = contrast + + # 亮度 (-1.0 到 1.0) / Brightness (-1.0 to 1.0) + if -1.0 <= brightness <= 1.0: + enhancement_controls["Brightness"] = brightness + + # 饱和度 (0.0-2.0) / Saturation (0.0-2.0) + if 0.0 <= saturation <= 2.0: + enhancement_controls["Saturation"] = saturation + + # 锐度 (0.0-2.0) / Sharpness (0.0-2.0) + if 0.0 <= sharpness <= 2.0: + enhancement_controls["Sharpness"] = sharpness + + if enhancement_controls: + self.camera.set_controls(enhancement_controls) + logger.info( + f"图像增强参数设置: 对比度={contrast}, 亮度={brightness}, 饱和度={saturation}, 锐度={sharpness}" + ) + return True + else: + logger.warning("所有增强参数都在有效范围外") + return False + + except Exception as e: + logger.error(f"设置图像增强参数失败: {e}") + return False + + def set_night_mode(self, enabled: bool) -> bool: + """设置夜间模式 / Set night mode""" + if not self.is_initialized: + logger.error("相机未初始化") + return False + + try: + if enabled: + # 夜间模式:提高增益,延长曝光时间,调整白平衡 / Night mode: increase gain, extend exposure time, adjust white balance + self.camera.set_controls( + { + "ExposureTime": max( + self.exposure_us, 30000 + ), # 至少30ms / At least 30ms + "AnalogueGain": max( + self.analogue_gain, 4.0 + ), # 至少4x增益 / At least 4x gain + "AwbEnable": False, + "ColourGains": (1.1, 0.9), # 偏暖色调 / warmer tones + "NoiseReductionMode": 2, # 中等降噪 / Moderate noise reduction + } + ) + logger.info("夜间模式已启用") + else: + # 关闭夜间模式:恢复默认设置 / Turn off night mode: restore default settings + self.camera.set_controls( + { + "ExposureTime": self.exposure_us, + "AnalogueGain": self.analogue_gain, + "AwbEnable": True, # 恢复自动白平衡 / Restore automatic white balance + "NoiseReductionMode": 0, # 关闭降噪 / Turn off noise reduction + } + ) + logger.info("夜间模式已关闭") + + return True + except Exception as e: + logger.error(f"设置夜间模式失败: {e}") + return False + + def set_color_mode(self, color_mode: str) -> bool: + """设置颜色模式 - 需要重新初始化相机 / Set color mode - camera reinitialization required""" + if color_mode not in ["color", "mono"]: + logger.error(f"不支持的颜色模式: {color_mode}") + return False + + if self.color_mode == color_mode: + logger.info(f"颜色模式已经是 {color_mode}") + return True + + try: + # 停止当前捕获 / Stop current capture + was_capturing = self.is_capturing + if was_capturing: + self.stop_capture() + + # 更新颜色模式 / Update color mode + self.color_mode = color_mode + + # 对于颜色模式,我们统一使用RGB888格式,在图像处理阶段进行转换 / For color mode, we uniformly use the RGB888 format and convert it during the image processing stage. + # 这样可以保持相机配置的一致性,避免格式兼容性问题 / This maintains consistency in camera configuration and avoids format compatibility issues + main_format = "RGB888" + + camera_config = self.camera.create_still_configuration( + main={ + "size": (self.capture_width, self.capture_height), + "format": main_format, + }, + raw={ + "size": (self.capture_width, self.capture_height), + "format": "SRGGB12", + }, + ) + + self.camera.configure(camera_config) + + # 如果之前在捕获,重新开始 / If capturing before, start again + if was_capturing: + self.start_capture() + + logger.info(f"颜色模式已切换为: {color_mode}") + return True + + except Exception as e: + logger.error(f"设置颜色模式失败: {e}") + return False + class CameraFactory: - """相机工厂类""" - + """相机工厂类 / Camera factory class""" + @staticmethod - def create_camera(camera_type: str, config: Dict[str, Any]) -> Optional[CameraInterface]: - """创建相机实例""" + def create_camera( + camera_type: str, config: dict[str, Any] + ) -> Optional[CameraInterface]: + """创建相机实例 / Create camera instance""" if camera_type == "imx327_mipi": return IMX327MIPICamera(config) else: @@ -499,8 +1121,8 @@ def create_camera(camera_type: str, config: Dict[str, Any]) -> Optional[CameraIn return None -# 兼容性函数,用于平滑迁移 -def create_camera(config: Dict[str, Any]) -> Optional[CameraInterface]: - """创建相机的便捷函数""" +# 兼容性函数,用于平滑迁移 / Compatibility function for smooth migration +def create_camera(config: dict[str, Any]) -> Optional[CameraInterface]: + """创建相机的便捷函数 / Convenience functions for creating cameras""" camera_type = config.get("type", "imx327_mipi") return CameraFactory.create_camera(camera_type, config) diff --git a/ogscope/hardware/gpio_config.py b/ogscope/hardware/gpio_config.py index 225bb81..956c19a 100644 --- a/ogscope/hardware/gpio_config.py +++ b/ogscope/hardware/gpio_config.py @@ -1,19 +1,19 @@ #!/usr/bin/env python3 -# -*- coding: utf-8 -*- """ GPIO 配置模块 适配 Raspberry Pi Zero 2W 的引脚布局 """ import logging -from typing import Dict, Any, Optional from enum import Enum +from typing import Any, Optional logger = logging.getLogger(__name__) class PinMode(Enum): - """引脚模式""" + """引脚模式 / pin mode""" + INPUT = "input" OUTPUT = "output" PWM = "pwm" @@ -22,210 +22,226 @@ class PinMode(Enum): class RaspberryPiZero2WGPIO: - """树莓派 Zero 2W GPIO 配置""" - - # GPIO 引脚定义 (BCM 编号) + """树莓派 Zero 2W GPIO 配置 / Raspberry Pi Zero 2W GPIO configuration""" + + # GPIO 引脚定义 (BCM 编号) / GPIO pin definition (BCM number) GPIO_PINS = { - # 电源引脚 - "3V3": 1, # 3.3V 电源 - "5V": 2, # 5V 电源 - "GND": 6, # 地线 - - # GPIO 引脚 - "GPIO2": 2, # SDA (I2C) - "GPIO3": 3, # SCL (I2C) - "GPIO4": 4, # 通用 GPIO - "GPIO5": 5, # 通用 GPIO - "GPIO6": 6, # 通用 GPIO - "GPIO7": 7, # SPI_CE1 - "GPIO8": 8, # SPI_CE0 - "GPIO9": 9, # SPI_MISO + # 电源引脚 / power pin + "3V3": 1, # 3.3V 电源 / 3.3V power supply + "5V": 2, # 5V 电源 / 5V power supply + "GND": 6, # 地线 / Ground wire + # GPIO 引脚 / GPIO pin + "GPIO2": 2, # SDA (I2C) + "GPIO3": 3, # SCL (I2C) + "GPIO4": 4, # 通用 GPIO / General purpose GPIO + "GPIO5": 5, # 通用 GPIO / General purpose GPIO + "GPIO6": 6, # 通用 GPIO / General purpose GPIO + "GPIO7": 7, # SPI_CE1 + "GPIO8": 8, # SPI_CE0 + "GPIO9": 9, # SPI_MISO "GPIO10": 10, # SPI_MOSI "GPIO11": 11, # SPI_CLK - "GPIO12": 12, # 通用 GPIO - "GPIO13": 13, # 通用 GPIO + "GPIO12": 12, # 通用 GPIO / General purpose GPIO + "GPIO13": 13, # 通用 GPIO / General purpose GPIO "GPIO14": 14, # TXD (UART) "GPIO15": 15, # RXD (UART) - "GPIO16": 16, # 通用 GPIO - "GPIO17": 17, # 通用 GPIO + "GPIO16": 16, # 通用 GPIO / General purpose GPIO + "GPIO17": 17, # 通用 GPIO / General purpose GPIO "GPIO18": 18, # PWM0 "GPIO19": 19, # PWM1 - "GPIO20": 20, # 通用 GPIO - "GPIO21": 21, # 通用 GPIO - "GPIO22": 22, # 通用 GPIO - "GPIO23": 23, # 通用 GPIO - "GPIO24": 24, # 通用 GPIO - "GPIO25": 25, # 通用 GPIO - "GPIO26": 26, # 通用 GPIO - "GPIO27": 27, # 通用 GPIO + "GPIO20": 20, # 通用 GPIO / General purpose GPIO + "GPIO21": 21, # 通用 GPIO / General purpose GPIO + "GPIO22": 22, # 通用 GPIO / General purpose GPIO + "GPIO23": 23, # 通用 GPIO / General purpose GPIO + "GPIO24": 24, # 通用 GPIO / General purpose GPIO + "GPIO25": 25, # 通用 GPIO / General purpose GPIO + "GPIO26": 26, # 通用 GPIO / General purpose GPIO + "GPIO27": 27, # 通用 GPIO / General purpose GPIO } - - # SPI 接口配置 + + # SPI 接口配置 / SPI interface configuration SPI_CONFIG = { - "bus": 0, # SPI 总线 - "device": 0, # SPI 设备 - "clock_pin": 11, # GPIO11 - SCLK - "miso_pin": 9, # GPIO9 - MISO - "mosi_pin": 10, # GPIO10 - MOSI - "cs_pin": 8, # GPIO8 - CS0 - "cs1_pin": 7, # GPIO7 - CS1 - "speed": 8000000, # SPI 时钟频率 (8MHz) + "bus": 0, # SPI 总线 / SPI bus + "device": 0, # SPI 设备 / SPI device + "clock_pin": 11, # GPIO11 - SCLK + "miso_pin": 9, # GPIO9 - MISO + "mosi_pin": 10, # GPIO10 - MOSI + "cs_pin": 8, # GPIO8 - CS0 + "cs1_pin": 7, # GPIO7 - CS1 + "speed": 8000000, # SPI 时钟频率 (8MHz) / SPI clock frequency (8MHz) } - - # I2C 接口配置 + + # I2C 接口配置 / I2C interface configuration I2C_CONFIG = { - "bus": 1, # I2C 总线 - "sda_pin": 2, # GPIO2 - SDA - "scl_pin": 3, # GPIO3 - SCL - "address": 0x3C, # 默认 I2C 地址 - "speed": 100000, # I2C 时钟频率 (100kHz) + "bus": 1, # I2C 总线 / I2C bus + "sda_pin": 2, # GPIO2 - SDA + "scl_pin": 3, # GPIO3 - SCL + "address": 0x3C, # 默认 I2C 地址 / Default I2C address + "speed": 100000, # I2C 时钟频率 (100kHz) / I2C clock frequency (100kHz) } - - # 显示屏 SPI 配置 + + # 显示屏 SPI 配置 / Display SPI configuration DISPLAY_SPI_CONFIG = { "bus": 0, "device": 0, - "dc_pin": 25, # 数据/命令选择引脚 - "rst_pin": 27, # 复位引脚 - "cs_pin": 8, # 片选引脚 - "backlight_pin": 18, # 背光控制 (PWM) + "dc_pin": 25, # 数据 / data + "rst_pin": 27, # 复位引脚 / reset pin + "cs_pin": 8, # 片选引脚 / Chip select pin + "backlight_pin": 18, # 背光控制 (PWM) / Backlight control (PWM) "width": 240, "height": 320, "rotation": 0, } - - # 按键 GPIO 配置 + + # 按键 GPIO 配置 / Button GPIO configuration BUTTON_CONFIG = { - "button1_pin": 4, # 按键1 - "button2_pin": 5, # 按键2 - "button3_pin": 6, # 按键3 - "button4_pin": 12, # 按键4 - "pull_up": True, # 内部上拉 - "debounce_ms": 50, # 防抖时间 + "button1_pin": 4, # 按键1 / Button 1 + "button2_pin": 5, # 按键2 / Button 2 + "button3_pin": 6, # 按键3 / Button 3 + "button4_pin": 12, # 按键4 / Button 4 + "pull_up": True, # 内部上拉 / Internal pull-up + "debounce_ms": 50, # 防抖时间 / Anti-shake time } - - # LED 配置 + + # LED 配置 / LED configuration LED_CONFIG = { - "status_led_pin": 16, # 状态 LED - "activity_led_pin": 20, # 活动 LED - "error_led_pin": 21, # 错误 LED + "status_led_pin": 16, # 状态 LED / Status LED + "activity_led_pin": 20, # 活动 LED / Activity LED + "error_led_pin": 21, # 错误 LED / Error LED + } + + # WiFi 应急短接(BCM):输出低 + 上拉输入,短接 ≥2s 切 STA;物理排针 15–16 相邻 + # WiFi emergency short (BCM): OUT low + pull-up IN; hold ≥2s forces STA; physical pins 15–16 adjacent + WIFI_EMERGENCY_SHORT_PINS = { + "out_bcm": 22, + "in_bcm": 23, } class GPIOConfig: - """GPIO 配置管理类""" - - def __init__(self, config: Dict[str, Any]): + """GPIO 配置管理类 / GPIO configuration management class""" + + def __init__(self, config: dict[str, Any]): self.config = config self.gpio_config = RaspberryPiZero2WGPIO() self._validate_config() - + def _validate_config(self): - """验证配置""" - # 检查显示屏配置 + """验证配置 / Verify configuration""" + # 检查显示屏配置 / Check display configuration if self.config.get("display", {}).get("enabled", False): display_config = self.config["display"] required_pins = ["dc_pin", "rst_pin"] for pin in required_pins: if pin not in display_config: logger.warning(f"显示屏配置缺少 {pin}") - - # 检查按键配置 + + # 检查按键配置 / Check button configuration button_config = self.config.get("buttons", {}) if button_config.get("enabled", False): - # 验证按键引脚配置 + # 验证按键引脚配置 / Verify button pin configuration pass - - def get_display_config(self) -> Dict[str, Any]: - """获取显示屏配置""" + + def get_display_config(self) -> dict[str, Any]: + """获取显示屏配置 / Get display configuration""" display_config = self.config.get("display", {}) if not display_config.get("enabled", False): return {} - - # 合并默认配置和用户配置 + + # 合并默认配置和用户配置 / Merge default configuration and user configuration config = self.gpio_config.DISPLAY_SPI_CONFIG.copy() config.update(display_config) - + return config - - def get_button_config(self) -> Dict[str, Any]: - """获取按键配置""" + + def get_button_config(self) -> dict[str, Any]: + """获取按键配置 / Get button configuration""" button_config = self.config.get("buttons", {}) if not button_config.get("enabled", False): return {} - + config = self.gpio_config.BUTTON_CONFIG.copy() config.update(button_config) - + return config - - def get_led_config(self) -> Dict[str, Any]: - """获取 LED 配置""" + + def get_led_config(self) -> dict[str, Any]: + """获取 LED 配置 / Get LED configuration""" led_config = self.config.get("leds", {}) if not led_config.get("enabled", False): return {} - + config = self.gpio_config.LED_CONFIG.copy() config.update(led_config) - + return config - - def get_spi_config(self) -> Dict[str, Any]: - """获取 SPI 配置""" + + def get_spi_config(self) -> dict[str, Any]: + """获取 SPI 配置 / Get SPI configuration""" return self.gpio_config.SPI_CONFIG.copy() - - def get_i2c_config(self) -> Dict[str, Any]: - """获取 I2C 配置""" + + def get_i2c_config(self) -> dict[str, Any]: + """获取 I2C 配置 / Get I2C configuration""" return self.gpio_config.I2C_CONFIG.copy() - + def validate_pin(self, pin_name: str) -> bool: - """验证引脚名称是否有效""" + """验证引脚名称是否有效 / Verify that the pin name is valid""" return pin_name in self.gpio_config.GPIO_PINS - + def get_pin_number(self, pin_name: str) -> Optional[int]: - """获取引脚编号""" + """获取引脚编号 / Get pin number""" return self.gpio_config.GPIO_PINS.get(pin_name) - + def get_all_used_pins(self) -> list: - """获取所有已使用的引脚""" + """获取所有已使用的引脚 / Get all used pins + + 注:WiFi 应急短接使用 BCM22/23,启用 `OGSCOPE_WIFI_EMERGENCY_GPIO_ENABLED` 时勿占用。 + Note: WiFi emergency uses BCM 22/23; avoid conflicts when emergency GPIO is enabled. + """ used_pins = [] - - # 显示屏引脚 + + # 显示屏引脚 / Display pins display_config = self.get_display_config() if display_config: - used_pins.extend([ - display_config.get("dc_pin"), - display_config.get("rst_pin"), - display_config.get("cs_pin"), - display_config.get("backlight_pin"), - ]) - - # 按键引脚 + used_pins.extend( + [ + display_config.get("dc_pin"), + display_config.get("rst_pin"), + display_config.get("cs_pin"), + display_config.get("backlight_pin"), + ] + ) + + # 按键引脚 / Button pin button_config = self.get_button_config() if button_config: - used_pins.extend([ - button_config.get("button1_pin"), - button_config.get("button2_pin"), - button_config.get("button3_pin"), - button_config.get("button4_pin"), - ]) - - # LED 引脚 + used_pins.extend( + [ + button_config.get("button1_pin"), + button_config.get("button2_pin"), + button_config.get("button3_pin"), + button_config.get("button4_pin"), + ] + ) + + # LED 引脚 / LED pin led_config = self.get_led_config() if led_config: - used_pins.extend([ - led_config.get("status_led_pin"), - led_config.get("activity_led_pin"), - led_config.get("error_led_pin"), - ]) - - # 移除 None 值 + used_pins.extend( + [ + led_config.get("status_led_pin"), + led_config.get("activity_led_pin"), + led_config.get("error_led_pin"), + ] + ) + + # 移除 None 值 / Remove None values used_pins = [pin for pin in used_pins if pin is not None] - + return used_pins -# 默认配置 +# 默认配置 / Default configuration DEFAULT_GPIO_CONFIG = { "display": { "enabled": False, diff --git a/ogscope/hardware/wifi_emergency_gpio.py b/ogscope/hardware/wifi_emergency_gpio.py new file mode 100644 index 0000000..8747e1b --- /dev/null +++ b/ogscope/hardware/wifi_emergency_gpio.py @@ -0,0 +1,130 @@ +""" +WiFi 应急 GPIO 监控:短接 2s 强制切回 STA +WiFi emergency GPIO watcher: short pins to force STA. +""" + +from __future__ import annotations + +import threading +import time +from dataclasses import dataclass + +from loguru import logger + +from ogscope.config import Settings, get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service +from ogscope.utils.environment import is_raspberry_pi + + +@dataclass +class _WatcherState: + low_since: float | None = None + last_trigger_at: float = 0.0 + + +class WifiEmergencyGpioMonitor: + """应急 GPIO 监控器 / Emergency GPIO monitor.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + self._thread: threading.Thread | None = None + self._stop_event = threading.Event() + self._gpio = None + self._state = _WatcherState() + + def start(self) -> None: + """启动监控线程 / Start monitor thread.""" + if not self._settings.wifi_emergency_gpio_enabled: + logger.info("应急 GPIO 未启用 / Emergency GPIO disabled by config") + return + if self._thread and self._thread.is_alive(): + return + if not is_raspberry_pi(): + logger.info("非树莓派环境,跳过应急 GPIO / Skip emergency GPIO on non-RPi") + return + try: + import RPi.GPIO as gpio # type: ignore + except Exception as e: + logger.warning( + "未安装 RPi.GPIO,无法启用应急短接 / RPi.GPIO unavailable: {}", e + ) + return + + self._gpio = gpio + self._setup_gpio() + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run_loop, + name="wifi-emergency-gpio", + daemon=True, + ) + self._thread.start() + logger.info( + "应急 GPIO 已启动 / Emergency GPIO monitor started: out={}, in={}, hold={}s", + self._settings.wifi_emergency_pin_out_bcm, + self._settings.wifi_emergency_pin_in_bcm, + self._settings.wifi_emergency_hold_seconds, + ) + + def stop(self) -> None: + """停止监控线程并释放 GPIO / Stop monitor and cleanup GPIO.""" + self._stop_event.set() + if self._thread and self._thread.is_alive(): + self._thread.join(timeout=1.5) + self._thread = None + if self._gpio: + try: + self._gpio.cleanup( + [ + self._settings.wifi_emergency_pin_out_bcm, + self._settings.wifi_emergency_pin_in_bcm, + ] + ) + except Exception: + pass + self._gpio = None + logger.info("应急 GPIO 已停止 / Emergency GPIO monitor stopped") + + def _setup_gpio(self) -> None: + assert self._gpio is not None + g = self._gpio + g.setwarnings(False) + g.setmode(g.BCM) + g.setup(self._settings.wifi_emergency_pin_out_bcm, g.OUT, initial=g.LOW) + g.setup(self._settings.wifi_emergency_pin_in_bcm, g.IN, pull_up_down=g.PUD_UP) + + def _run_loop(self) -> None: + assert self._gpio is not None + g = self._gpio + interval = 0.05 + hold = self._settings.wifi_emergency_hold_seconds + while not self._stop_event.is_set(): + now = time.monotonic() + pin_low = g.input(self._settings.wifi_emergency_pin_in_bcm) == g.LOW + if pin_low: + if self._state.low_since is None: + self._state.low_since = now + if (now - self._state.low_since) >= hold: + if (now - self._state.last_trigger_at) >= hold: + self._state.last_trigger_at = now + self._force_sta() + else: + self._state.low_since = None + time.sleep(interval) + + def _force_sta(self) -> None: + logger.warning( + "检测到应急短接,强制切换 STA / Emergency short detected, forcing STA" + ) + if not wifi_switch_service.is_configured(): + logger.error( + "WiFi 未配置,无法应急切 STA / WiFi not configured, cannot force STA" + ) + return + try: + wifi_switch_service.switch("sta") + except Exception as e: + logger.error("应急切 STA 失败 / Failed to force STA: {}", e) + + +wifi_emergency_gpio_monitor = WifiEmergencyGpioMonitor() diff --git a/ogscope/hardware/wifi_switch.py b/ogscope/hardware/wifi_switch.py new file mode 100644 index 0000000..1b44723 --- /dev/null +++ b/ogscope/hardware/wifi_switch.py @@ -0,0 +1,117 @@ +""" +WiFi 模式切换(调用 ogscope-wifi-switch.sh + nmcli) +WiFi mode switch via external NetworkManager helper script. +""" + +from __future__ import annotations + +import os +import shlex +import subprocess +from pathlib import Path +from typing import Literal + +from loguru import logger + +from ogscope.config import Settings, get_settings + +WifiMode = Literal["ap", "sta", "unknown"] + + +def _wifi_env(settings: Settings) -> dict[str, str]: + """合并当前环境与 WiFi 相关 OGSCOPE_* 变量 / Merge env with WiFi OGSCOPE_* vars.""" + env = {k: v for k, v in os.environ.items() if v is not None} + env["OGSCOPE_WIFI_STA_CONNECTION"] = settings.wifi_sta_connection + env["OGSCOPE_WIFI_AP_CONNECTION"] = settings.wifi_ap_connection + env["OGSCOPE_WIFI_INTERFACE"] = settings.wifi_interface + return env + + +def _parse_status_output(text: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in text.splitlines(): + line = line.strip() + if "=" in line: + k, _, v = line.partition("=") + out[k.strip()] = v.strip() + return out + + +class WifiSwitchService: + """封装脚本调用 / Script invocation wrapper.""" + + def __init__(self, settings: Settings | None = None) -> None: + self._settings = settings or get_settings() + + def is_configured(self) -> bool: + """是否已配置连接名与脚本 / Whether connection names and script are set.""" + s = self._settings + if not s.wifi_sta_connection or not s.wifi_ap_connection: + return False + p = Path(s.wifi_switch_script) + return p.is_file() + + def get_status(self) -> dict[str, str | None]: + """执行 status,返回解析后的键值 / Run status subcommand.""" + if not self.is_configured(): + return { + "MODE": "unknown", + "error": "wifi_not_configured", + } + raw = self._run_script("status", check=False) + data = _parse_status_output(raw) + if not data.get("MODE"): + data["MODE"] = "unknown" + return data + + def switch(self, mode: Literal["ap", "sta"]) -> None: + """切换模式;失败抛 subprocess.CalledProcessError / Switch mode.""" + if not self.is_configured(): + raise RuntimeError("wifi_not_configured") + self._run_script(mode, check=True) + + def _run_script(self, subcommand: str, *, check: bool) -> str: + s = self._settings + script = Path(s.wifi_switch_script) + cmd: list[str] = [] + if s.wifi_switch_use_sudo: + # 不用 sudo -E:多数 sudoers 禁止 preserve env;脚本会从 /etc/ogscope/network.env 读取 + # Avoid sudo -E (often blocked); switch script sources network.env when env is empty + cmd.extend(["sudo", "-n", str(script), subcommand]) + else: + cmd.extend([str(script), subcommand]) + logger.info( + "WiFi script: {} / Running WiFi script", + " ".join(shlex.quote(c) for c in cmd), + ) + try: + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=s.wifi_switch_timeout_seconds, + env=_wifi_env(s), + check=check, + ) + except subprocess.TimeoutExpired as e: + logger.error("WiFi 脚本超时 / WiFi script timeout: {}", e) + raise + out = (proc.stdout or "").strip() + err = (proc.stderr or "").strip() + combined = "\n".join(x for x in (out, err) if x) + if err: + logger.debug("WiFi script stderr / stderr: {}", err) + if proc.returncode != 0 and check: + logger.warning( + "WiFi 脚本失败 rc={} / script failed: {}\n{}", + proc.returncode, + out, + err, + ) + raise subprocess.CalledProcessError( + proc.returncode, cmd, output=out, stderr=err + ) + return combined + + +wifi_switch_service = WifiSwitchService() diff --git a/ogscope/main.py b/ogscope/main.py index 5a50a8d..e82bbb5 100644 --- a/ogscope/main.py +++ b/ogscope/main.py @@ -1,10 +1,9 @@ """ OGScope 主程序入口 """ + import asyncio import sys -from pathlib import Path -from typing import Optional import uvicorn from loguru import logger @@ -15,33 +14,33 @@ def setup_environment() -> Settings: - """初始化环境""" - # 加载配置 + """初始化环境 / Initialize environment""" + # 加载配置 / Load configuration settings = get_settings() - - # 配置日志 + + # 配置日志 / Configuration log setup_logging(settings.log_level, settings.log_file) - + logger.info(f"OGScope v{__version__} 启动中...") logger.info(f"运行环境: {settings.environment}") logger.info(f"日志级别: {settings.log_level}") - + return settings async def main() -> int: - """主函数""" + """主函数 / main function""" try: settings = setup_environment() - - # TODO: 初始化各个模块 - # - 相机模块 - # - 显示模块 - # - 算法模块 - - # 启动 FastAPI Web 服务 + + # TODO: 初始化各个模块 / TODO: Initialize each module + # - 相机模块 / - camera module + # - 显示模块 / - Display module + # - 算法模块 / - Algorithm module + + # 启动 FastAPI Web 服务 / Start the FastAPI web service logger.info(f"启动 Web 服务: http://{settings.host}:{settings.port}") - + config = uvicorn.Config( "ogscope.web.app:app", host=settings.host, @@ -51,9 +50,9 @@ async def main() -> int: ) server = uvicorn.Server(config) await server.serve() - + return 0 - + except KeyboardInterrupt: logger.info("收到退出信号 (Ctrl+C)") return 0 @@ -65,11 +64,10 @@ async def main() -> int: def cli() -> None: - """命令行入口点""" + """命令行入口点 / Command line entry point""" exit_code = asyncio.run(main()) sys.exit(exit_code) if __name__ == "__main__": cli() - diff --git a/ogscope/utils/environment.py b/ogscope/utils/environment.py new file mode 100644 index 0000000..cbc58be --- /dev/null +++ b/ogscope/utils/environment.py @@ -0,0 +1,187 @@ +""" +环境检测模块 +检测是否在树莓派环境中运行 +""" + +import importlib.util +import os +import platform +from pathlib import Path + + +def is_raspberry_pi() -> bool: + """ + 检测是否在树莓派环境中运行 + + Returns: + bool: 如果是树莓派环境返回True,否则返回False + """ + try: + # 方法1: 检查 / Method 1: Check + if Path("/proc/cpuinfo").exists(): + with open("/proc/cpuinfo") as f: + cpuinfo = f.read() + if "BCM" in cpuinfo or "Raspberry Pi" in cpuinfo: + return True + + # 方法2: 检查 / Method 2: Check + if Path("/proc/device-tree/model").exists(): + with open("/proc/device-tree/model") as f: + model = f.read() + if "Raspberry Pi" in model: + return True + + # 方法3: 检查环境变量 / Method 3: Check environment variables + if os.environ.get("RASPBERRY_PI") == "1": + return True + + # 方法4: 检查是否存在树莓派特有的GPIO库 / Method 4: Raspberry Pi GPIO module + if importlib.util.find_spec("RPi.GPIO") is not None: + return True + + # 方法5: 检查是否存在 picamera2 / Method 5: picamera2 module + if importlib.util.find_spec("picamera2") is not None: + return True + + except Exception: + pass + + return False + + +def get_device_info() -> dict: + """ + 获取设备信息 + + Returns: + dict: 设备信息字典 + """ + info = { + "platform": platform.system(), + "machine": platform.machine(), + "processor": platform.processor(), + "is_raspberry_pi": is_raspberry_pi(), + "python_version": platform.python_version(), + } + + # 如果是Linux系统,尝试获取更多信息 / If it is a Linux system, try to get more information + if platform.system() == "Linux": + try: + # 获取CPU信息 / Get CPU information + if Path("/proc/cpuinfo").exists(): + with open("/proc/cpuinfo") as f: + cpuinfo = f.read() + if "Hardware" in cpuinfo: + for line in cpuinfo.split("\n"): + if line.startswith("Hardware"): + info["hardware"] = line.split(":")[1].strip() + break + if "Model" in cpuinfo: + for line in cpuinfo.split("\n"): + if line.startswith("Model"): + info["model"] = line.split(":")[1].strip() + break + + # 获取内存信息 / Get memory information + if Path("/proc/meminfo").exists(): + with open("/proc/meminfo") as f: + meminfo = f.read() + for line in meminfo.split("\n"): + if line.startswith("MemTotal"): + info["memory_total"] = line.split(":")[1].strip() + break + + except Exception: + pass + + return info + + +def get_camera_capabilities() -> dict: + """ + 获取相机能力信息 + + Returns: + dict: 相机能力信息 + """ + capabilities = { + "has_picamera2": False, + "has_opencv_camera": False, + "has_usb_camera": False, + "available_cameras": [], + } + + # 检查 picamera2 / Check picamera2 + if importlib.util.find_spec("picamera2") is not None: + capabilities["has_picamera2"] = True + capabilities["available_cameras"].append("picamera2") + + # 检查OpenCV相机 / Check OpenCV camera + try: + import cv2 + + cap = cv2.VideoCapture(0) + if cap.isOpened(): + capabilities["has_opencv_camera"] = True + capabilities["available_cameras"].append("opencv") + cap.release() + except Exception: + pass + + # 检查USB相机 / Check USB camera + try: + import cv2 + + for i in range(5): # 检查前5个设备 / Check top 5 devices + cap = cv2.VideoCapture(i) + if cap.isOpened(): + capabilities["has_usb_camera"] = True + capabilities["available_cameras"].append(f"usb_camera_{i}") + cap.release() + break + except Exception: + pass + + return capabilities + + +def should_use_simulation_mode() -> bool: + """ + 判断是否应该使用模拟模式 + + Returns: + bool: 如果应该使用模拟模式返回True + """ + # 强制使用模拟模式的环境变量 / Environment variables to force use of simulation mode + if os.environ.get("OGSCOPE_SIMULATION_MODE") == "1": + return True + + # 强制禁用模拟模式的环境变量 / Environment variable to force disabling of simulation mode + if os.environ.get("OGSCOPE_SIMULATION_MODE") == "0": + return False + + # 默认逻辑:非树莓派环境使用模拟模式 / Default logic: non-Raspberry Pi environments use simulation mode + return not is_raspberry_pi() + + +def get_simulation_config() -> dict: + """ + 获取模拟模式配置 + + Returns: + dict: 模拟模式配置 + """ + return { + "enabled": should_use_simulation_mode(), + "virtual_resolution": (1920, 1080), + "virtual_fps": 30, + "virtual_exposure": 10000, # 微秒 / microseconds + "virtual_gain": 1.0, + "star_field_density": 0.1, # 星点密度 / Star point density + "polar_star_position": ( + 0.5, + 0.3, + ), # 极轴星位置 (x, y) / Polar star position (x, y) + "noise_level": 0.05, # 噪声水平 / noise level + "atmospheric_turbulence": True, # 大气湍流效果 / atmospheric turbulence effect + } diff --git a/ogscope/utils/logging_config.py b/ogscope/utils/logging_config.py index 46f54a7..ea43515 100644 --- a/ogscope/utils/logging_config.py +++ b/ogscope/utils/logging_config.py @@ -1,6 +1,7 @@ """ 日志配置模块 """ + import sys from pathlib import Path from typing import Optional @@ -14,40 +15,39 @@ def setup_logging( ) -> None: """ 配置 Loguru 日志系统 - + Args: level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) log_file: 日志文件路径,None 表示不输出到文件 """ - # 移除默认的 handler + # 移除默认的 handler / Remove default handler logger.remove() - - # 添加控制台输出 handler + + # 添加控制台输出 handler / Add console output handler logger.add( sys.stderr, format="{time:YYYY-MM-DD HH:mm:ss} | " - "{level: <8} | " - "{name}:{function}:{line} | " - "{message}", + "{level: <8} | " + "{name}:{function}:{line} | " + "{message}", level=level, colorize=True, ) - - # 添加文件输出 handler(如果指定) + + # 添加文件输出 handler(如果指定) / Add file output handler (if specified) if log_file: log_file = Path(log_file) log_file.parent.mkdir(parents=True, exist_ok=True) - + logger.add( log_file, format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | " - "{name}:{function}:{line} | {message}", + "{name}:{function}:{line} | {message}", level=level, - rotation="10 MB", # 日志文件大小达到 10MB 时轮转 - retention="30 days", # 保留 30 天的日志 - compression="zip", # 压缩旧日志 - enqueue=True, # 异步写入 + rotation="10 MB", # 日志文件大小达到 10MB 时轮转 / Rotate log files when size reaches 10MB + retention="30 days", # 保留 30 天的日志 / Keep logs for 30 days + compression="zip", # 压缩旧日志 / Compress old logs + enqueue=True, # 异步写入 / Asynchronous writing ) - - logger.info(f"日志文件: {log_file.absolute()}") + logger.info(f"日志文件: {log_file.absolute()}") diff --git a/ogscope/utils/virtual_stream.py b/ogscope/utils/virtual_stream.py new file mode 100644 index 0000000..8c02029 --- /dev/null +++ b/ogscope/utils/virtual_stream.py @@ -0,0 +1,307 @@ +""" +虚拟视频流生成器 +用于开发环境模拟相机视频流 +""" + +import math +import random +import time +from typing import Optional + +import cv2 +import numpy as np + + +class VirtualVideoStream: + """虚拟视频流生成器 / Virtual video stream generator""" + + def __init__(self, width: int = 1920, height: int = 1080, fps: int = 30): + self.width = width + self.height = height + self.fps = fps + self.frame_time = 1.0 / fps + self.last_frame_time = 0 + + # 模拟参数 / Simulation parameters + self.star_field_density = 0.1 + self.polar_star_position = (0.5, 0.3) # 极轴星位置 / polar star position + self.noise_level = 0.05 + self.atmospheric_turbulence = True + + # 生成星点数据 / Generate star point data + self.stars = self._generate_star_field() + + # 大气湍流参数 / Atmospheric turbulence parameters + self.turbulence_offset = 0 + self.turbulence_speed = 0.1 + + # 时间戳 / Timestamp + self.start_time = time.time() + + def _generate_star_field(self) -> list: + """生成星点数据 / Generate star point data""" + stars = [] + num_stars = int(self.width * self.height * self.star_field_density / 10000) + + for _ in range(num_stars): + # 随机位置 / random location + x = random.uniform(0, 1) + y = random.uniform(0, 1) + + # 随机星等 (1-6等) / Random magnitude (mag 1-6) + magnitude = random.uniform(1.0, 6.0) + + # 根据星等计算亮度 / Calculate brightness based on magnitude + brightness = max(0, 1.0 - (magnitude - 1) / 5.0) + + # 星点大小 / Star point size + size = max(1, int(3 * brightness)) + + stars.append( + { + "x": x, + "y": y, + "magnitude": magnitude, + "brightness": brightness, + "size": size, + "twinkle_phase": random.uniform(0, 2 * math.pi), + } + ) + + # 添加极轴星(北极星) / Added Polaris (Polaris) + stars.append( + { + "x": self.polar_star_position[0], + "y": self.polar_star_position[1], + "magnitude": 2.0, + "brightness": 0.8, + "size": 4, + "twinkle_phase": 0, + "is_polar_star": True, + } + ) + + return stars + + def _apply_atmospheric_turbulence(self, image: np.ndarray) -> np.ndarray: + """应用大气湍流效果 / Apply atmospheric turbulence effects""" + if not self.atmospheric_turbulence: + return image + + # 简单的湍流效果:轻微的位置偏移 / Simple turbulence effect: slight position shift + self.turbulence_offset += self.turbulence_speed + + # 创建湍流偏移 / Create turbulence offset + turbulence_x = int(2 * math.sin(self.turbulence_offset)) + turbulence_y = int(1 * math.cos(self.turbulence_offset * 1.3)) + + # 应用偏移 / Apply offset + if turbulence_x != 0 or turbulence_y != 0: + M = np.float32([[1, 0, turbulence_x], [0, 1, turbulence_y]]) + image = cv2.warpAffine(image, M, (self.width, self.height)) + + return image + + def _add_noise(self, image: np.ndarray) -> np.ndarray: + """添加噪声 / add noise""" + if self.noise_level <= 0: + return image + + # 生成随机噪声 / Generate random noise + noise = np.random.normal(0, self.noise_level * 255, image.shape).astype( + np.uint8 + ) + + # 添加噪声到图像 / Add noise to image + noisy_image = cv2.add(image, noise) + + return noisy_image + + def _draw_stars(self, image: np.ndarray) -> np.ndarray: + """绘制星点 / Draw star points""" + current_time = time.time() + + for star in self.stars: + # 计算闪烁效果 / Calculate the flicker effect + twinkle = 0.8 + 0.2 * math.sin(star["twinkle_phase"] + current_time * 2) + brightness = star["brightness"] * twinkle + + # 计算像素位置 / Calculate pixel position + x = int(star["x"] * self.width) + y = int(star["y"] * self.height) + + # 绘制星点 / Draw star points + size = star["size"] + color = int(255 * brightness) + + # 绘制星点(圆形) / Draw star points (circles) + cv2.circle(image, (x, y), size, (color, color, color), -1) + + # 为亮星添加十字光芒 / Add cross rays to bright stars + if brightness > 0.7: + # 水平线 / horizontal line + cv2.line( + image, + (x - size * 2, y), + (x + size * 2, y), + (color, color, color), + 1, + ) + # 垂直线 / vertical line + cv2.line( + image, + (x, y - size * 2), + (x, y + size * 2), + (color, color, color), + 1, + ) + + # 为极轴星添加特殊标记 / Add special markers to polar stars + if star.get("is_polar_star"): + # 绘制极轴星标记 / Draw polar star markers + cv2.circle( + image, (x, y), size + 2, (0, 255, 255), 2 + ) # 黄色圆圈 / yellow circle + # 添加文字标记 / Add text tag + cv2.putText( + image, + "Polaris", + (x + 10, y - 10), + cv2.FONT_HERSHEY_SIMPLEX, + 0.5, + (0, 255, 255), + 1, + ) + + return image + + def _draw_crosshair(self, image: np.ndarray) -> np.ndarray: + """绘制十字准星 / draw crosshair""" + center_x = self.width // 2 + center_y = self.height // 2 + + # 绘制十字准星 / draw crosshair + color = (255, 0, 0) # 红色 / red + thickness = 2 + + # 水平线 / horizontal line + cv2.line( + image, + (center_x - 20, center_y), + (center_x + 20, center_y), + color, + thickness, + ) + # 垂直线 / vertical line + cv2.line( + image, + (center_x, center_y - 20), + (center_x, center_y + 20), + color, + thickness, + ) + + # 中心圆 / central circle + cv2.circle(image, (center_x, center_y), 8, color, thickness) + + return image + + def _draw_coordinate_grid(self, image: np.ndarray) -> np.ndarray: + """绘制坐标网格 / Draw coordinate grid""" + color = (64, 64, 64) # 深灰色 / dark gray + thickness = 1 + + # 绘制网格线 / Draw grid lines + for i in range(0, self.width, self.width // 10): + cv2.line(image, (i, 0), (i, self.height), color, thickness) + + for i in range(0, self.height, self.height // 10): + cv2.line(image, (0, i), (self.width, i), color, thickness) + + return image + + def generate_frame(self) -> bytes: + """生成一帧图像 / generate a frame of image""" + current_time = time.time() + + # 控制帧率 / 控制帧率 + if current_time - self.last_frame_time < self.frame_time: + time.sleep(self.frame_time - (current_time - self.last_frame_time)) + + self.last_frame_time = time.time() + + # 创建黑色背景 / Create a black background + image = np.zeros((self.height, self.width, 3), dtype=np.uint8) + + # 绘制坐标网格 / Draw coordinate grid + image = self._draw_coordinate_grid(image) + + # 绘制星点 / Draw star points + image = self._draw_stars(image) + + # 绘制十字准星 / draw crosshair + image = self._draw_crosshair(image) + + # 应用大气湍流 / Apply atmospheric turbulence + image = self._apply_atmospheric_turbulence(image) + + # 添加噪声 / add noise + image = self._add_noise(image) + + # 转换为JPEG / Convert to JPEG + _, buffer = cv2.imencode(".jpg", image, [cv2.IMWRITE_JPEG_QUALITY, 85]) + + return buffer.tobytes() + + def get_star_positions(self) -> list: + """获取当前星点位置(用于校准) / Get the current star point position (for calibration)""" + return [ + { + "x": star["x"] * self.width, + "y": star["y"] * self.height, + "magnitude": star["magnitude"], + "name": "Polaris" if star.get("is_polar_star") else f"Star_{i}", + } + for i, star in enumerate(self.stars) + ] + + def update_polar_star_position(self, x: float, y: float): + """更新极轴星位置 / Update polar star position""" + self.polar_star_position = (x, y) + # 更新极轴星位置 / Update polar star position + for star in self.stars: + if star.get("is_polar_star"): + star["x"] = x + star["y"] = y + break + + def set_simulation_parameters(self, **kwargs): + """设置模拟参数 / Set simulation parameters""" + if "star_field_density" in kwargs: + self.star_field_density = kwargs["star_field_density"] + self.stars = self._generate_star_field() + + if "noise_level" in kwargs: + self.noise_level = kwargs["noise_level"] + + if "atmospheric_turbulence" in kwargs: + self.atmospheric_turbulence = kwargs["atmospheric_turbulence"] + + +# 全局虚拟视频流实例 / Global virtual video stream instance +_virtual_stream: Optional[VirtualVideoStream] = None + + +def get_virtual_stream() -> VirtualVideoStream: + """获取虚拟视频流实例 / Get virtual video streaming instance""" + global _virtual_stream + if _virtual_stream is None: + _virtual_stream = VirtualVideoStream() + return _virtual_stream + + +def create_virtual_stream( + width: int = 1920, height: int = 1080, fps: int = 30 +) -> VirtualVideoStream: + """创建新的虚拟视频流实例 / Create a new virtual video stream instance""" + return VirtualVideoStream(width, height, fps) diff --git a/ogscope/vendor/README.md b/ogscope/vendor/README.md new file mode 100644 index 0000000..4630c16 --- /dev/null +++ b/ogscope/vendor/README.md @@ -0,0 +1,8 @@ +# Vendored cedar-solve (`tetra3`) + +本目录为 **[cedar-solve](https://github.com/smroid/cedar-solve)** 仓库中的 `tetra3` 包完整拷贝(与 PyPI [`cedar-solve`](https://pypi.org/project/cedar-solve/) 同源),便于离线部署与锁定版本;**非自研解算算法**。 + +This folder is the upstream **`tetra3`** package from cedar-solve (same family as PyPI `cedar-solve`), vendored for offline boards — **not a reimplementation**. + +- 许可证 / License: [tetra3/LICENSE.txt](tetra3/LICENSE.txt)(Apache-2.0) +- `tetra3/data/default_database.npz` 体积大,不随 Git 提交;请从 cedar-solve 源码包复制到 `data/plate_solve/` 或 `tetra3/data/` / Large `default_database.npz` is not committed; copy from cedar-solve release or your `cedar-solve-master/tetra3/data/`. diff --git a/ogscope/vendor/tetra3/LICENSE.txt b/ogscope/vendor/tetra3/LICENSE.txt new file mode 100644 index 0000000..a4a2080 --- /dev/null +++ b/ogscope/vendor/tetra3/LICENSE.txt @@ -0,0 +1,199 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +----------------------------------------------------------------------------- + +Original license notice for Tetra, of which tetra3 is a derivative: +Copyright (c) 2016 brownj4 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ogscope/vendor/tetra3/__init__.py b/ogscope/vendor/tetra3/__init__.py new file mode 100644 index 0000000..6f0615e --- /dev/null +++ b/ogscope/vendor/tetra3/__init__.py @@ -0,0 +1,5 @@ +name = "tetra3" + +from .tetra3 import Tetra3, get_centroids_from_image, crop_and_downsample_image + +__all__ = ['Tetra3', 'get_centroids_from_image', 'crop_and_downsample_image'] diff --git a/ogscope/vendor/tetra3/benchmark_synthetic_fovs.py b/ogscope/vendor/tetra3/benchmark_synthetic_fovs.py new file mode 100644 index 0000000..8d2fccd --- /dev/null +++ b/ogscope/vendor/tetra3/benchmark_synthetic_fovs.py @@ -0,0 +1,155 @@ +# Copyright (c) 2024 Steven Rosenthal smr@dt3.org +# See LICENSE file in root directory for license terms. + +import math + +import numpy as np +import pytest +from scipy.spatial.transform import Rotation as R + +import tetra3 +from tetra3 import fov_util + +""" +Test utility to enumerate test FOVs from a star catalog and evaluate +Cedar's performance solving them. Adapted from code provided by Iain Clark. + +Note: Angle values are in radians unless suffixed with _deg. +""" + +def _ra_dec_from_vector(vec): + """Returns (ra, dec) from the given (x, y, z) star vector.""" + x, y, z = vec + ra = math.atan2(y, x) + dec = math.asin(z) + return (ra, dec) + + +def benchmark_synthetic_fovs(width, height, fov_deg, num_fovs, + num_centroids=20, database='default_database'): + """Synthesizes and solves star fields. + width, height: pixel count of camera + fov_deg: horizontal FOV, in degrees + num_fovs: Number of FOVs to generate. 2n + 1 FOVs are actually generated. + 0 generates a single FOV; 1 generates 3 FOVs, etc. + num_centroids: max number of centroids to pass to solver. + + Returns: dict with the following fields: + num_successes + num_failures + mean_solve_time_ms + max_solve_time_ms + solve_time_histo + histo_bin_width_ms + """ + + # TODO: apply noise to x/y centroids; apply noise to brightness ranking. + + diag_pixels = math.sqrt(width * width + height * height) + diag_fov = np.deg2rad(fov_deg * diag_pixels / width) + scale_factor = width / 2 / np.tan(np.deg2rad(fov_deg) / 2) + + # Histogram of successful solve times. + NUM_HISTO_BINS = 1000 + MAX_SOLVE_TIME_MS = 1000 + solve_time_histo = [0] * NUM_HISTO_BINS + bin_width = MAX_SOLVE_TIME_MS / NUM_HISTO_BINS + + total_solve_time_ms = 0 + max_solve_time_ms = 0 + num_successes = 0 + num_failures = 0 + + t3 = tetra3.Tetra3(load_database=database) + + print('Start solving...') + iter_count = 0 + for center_vec in fov_util.fibonacci_sphere_lattice(num_fovs): + iter_count += 1 + + ra, dec = _ra_dec_from_vector(center_vec) + if ra < 0: + ra += 2 * np.pi + + nearby_star_inds = t3._get_nearby_catalog_stars(center_vec, diag_fov / 2) + nearby_stars = t3.star_table[nearby_star_inds] + + nearby_ra = nearby_stars.transpose()[0] + nearby_dec = nearby_stars.transpose()[1] + + # un-rotate RA + nearby_ra_rot = nearby_ra - ra + + # convert rotated to cartesian + proj_xyz = np.zeros([3, nearby_ra.shape[0]]) + proj_xyz[0] = np.cos(nearby_ra_rot) * np.cos(nearby_dec) # x + proj_xyz[1] = np.sin(nearby_ra_rot) * np.cos(nearby_dec) # y + proj_xyz[2] = np.sin(nearby_dec) # z + + # rotate to remove dec of target star + # rotate from xy plane parallel to xz plane to +ve Z to zero declination + r = R.from_rotvec([0, (-np.pi / 2 + dec), 0]) + proj_xyz = r.apply(proj_xyz.transpose()).transpose() + + # project stars on z=1 plane perpendicular to boresight + proj_xyz[0] = proj_xyz[0] / proj_xyz[2] + proj_xyz[1] = proj_xyz[1] / proj_xyz[2] + + # scale to image pixels + proj_xyz_scaled = proj_xyz * scale_factor + proj_xyz_scaled[0] = proj_xyz_scaled[0] + width / 2 + proj_xyz_scaled[1] = proj_xyz_scaled[1] + height / 2 + + centroids = [] + for index in range(len(proj_xyz_scaled[0])): + x = proj_xyz_scaled[0][index] + y = proj_xyz_scaled[1][index] + # Only keep centroids within the image area. Add a small border, reflects + # that Cedar-Detect cannot detect at edge. + if x < 2 or y < 2 or x >= width - 2 or y >= height - 2: + continue + centroids.append((y, x)) + if len(centroids) >= num_centroids: + break # Keep only num_centroids brightest centroids. + + solution = t3.solve_from_centroids(centroids, size=(height, width), distortion=0, + fov_estimate=fov_deg, fov_max_error=fov_deg/10.0) + # Print progress 10 times. + if iter_count % (num_fovs / 5) == 0: + print(f'iter {iter_count}; solution for ra/dec {np.rad2deg(ra):.4f}/{np.rad2deg(dec):.4f}: {solution}') + + if solution['RA'] is None: + num_failures += 1 + continue + + num_successes += 1 + + tol = 0.05 + # We don't handle proper motion very close to the poles, so use a larger tolerance. + if abs(np.rad2deg(dec)) > (90 - fov_deg/2): + tol = 0.5 + ra_diff = np.rad2deg(ra) - solution['RA'] + if ra_diff > 180: + ra_diff -= 360 + if ra_diff < -180: + ra_diff += 360 + if abs(ra_diff) > tol: + pytest.fail(f"'expected RA {np.rad2deg(ra)}, got {solution['RA']} (dec {solution['Dec']})'") + if abs(np.rad2deg(dec) - solution['Dec']) > tol: + pytest.fail(f"expected Dec {np.rad2deg(dec)}, got {solution['Dec']}") + + total_solve_time_ms += solution['T_solve'] + time_ms = int(solution['T_solve']) + max_solve_time_ms = max(time_ms, max_solve_time_ms) + histo_bin = int(time_ms / bin_width) + if histo_bin >= len(solve_time_histo): + histo_bin = len(solve_time_histo) - 1 + solve_time_histo[histo_bin] += 1 + + return {'num_successes': num_successes, + 'num_failures': num_failures, + 'mean_solve_time_ms': total_solve_time_ms / num_successes, + 'max_solve_time_ms': max_solve_time_ms, + 'solve_time_histo': solve_time_histo, + 'histo_bin_width_ms': bin_width + } diff --git a/ogscope/vendor/tetra3/bin/README.txt b/ogscope/vendor/tetra3/bin/README.txt new file mode 100644 index 0000000..f685f64 --- /dev/null +++ b/ogscope/vendor/tetra3/bin/README.txt @@ -0,0 +1,6 @@ +This directory should be populated with an executable named +'cedar-detect-server'. This binary is a gRPC server that implements the +CedarDetect service declared at ../proto/cedar_detect.proto. + +The binary should be built from the Rust source at +https://github.com/smroid/cedar-detect. diff --git a/ogscope/vendor/tetra3/breadth_first_combinations.py b/ogscope/vendor/tetra3/breadth_first_combinations.py new file mode 100644 index 0000000..0c950f3 --- /dev/null +++ b/ogscope/vendor/tetra3/breadth_first_combinations.py @@ -0,0 +1,18 @@ +# Copyright (c) 2024 Steven Rosenthal smr@dt3.org +# See LICENSE file in root directory for license terms. + +# Developed by smr@dt3.org; please let them know if this already exists somewhere. + +def breadth_first_combinations(sequence, r): + """ Variant of itertools.combinations() that is breadth-first rather than depth-first. """ + if r == 1: + for item in sequence: + yield (item,) + return + + index = r - 1 + while index < len(sequence): + right_most_elt = sequence[index] + for prefix_combination in breadth_first_combinations(sequence[:index], r-1): + yield prefix_combination + (right_most_elt,) + index += 1 diff --git a/ogscope/vendor/tetra3/cedar_detect_client.py b/ogscope/vendor/tetra3/cedar_detect_client.py new file mode 100644 index 0000000..c0caf0f --- /dev/null +++ b/ogscope/vendor/tetra3/cedar_detect_client.py @@ -0,0 +1,182 @@ +# Copyright (c) 2024 Steven Rosenthal smr@dt3.org +# See LICENSE file in root directory for license terms. + +from __future__ import annotations +import logging +import os +import subprocess +import time +from pathlib import Path +from typing import Union + +import grpc +from multiprocessing import shared_memory +import numpy as np + +from tetra3 import cedar_detect_pb2, cedar_detect_pb2_grpc + +_bin_dir = Path(__file__).parent / "bin" + + +class CedarDetectClient: + """Executes the cedar-detect-server binary as a subprocess. That binary is a + gRPC server described by the tetra3/proto/cedar_detect.proto file. + """ + + def __init__(self, logger = None, binary_path: Union[Path, str, None] = None, port=50051): + """Spawns the cedar-detect-server subprocess. + + Args: + logger: If have a logger object, pass it in here. Otherwise one will be created + locally. + binary_path: If you wish to specify a custom location for the `cedar-detect-server` binary you + may do so, otherwise the default is to search in the relative directory "./bin" + port: Customize the `cedar-detect-server` port if running multiple instances. + """ + if logger is None: + self._logger = logging.getLogger('CedarDetectClient') + # Add new handlers to the logger. + self._logger.setLevel(logging.DEBUG) + # Console handler at INFO level + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter( + logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s')) + self._logger.addHandler(ch) + else: + self._logger = logger + self._binary_path: Path = Path(binary_path) if binary_path else _bin_dir / "cedar-detect-server" + if not self._binary_path.exists() or not self._binary_path.is_file(): + raise ValueError(f"The cedar-detect-server binary could not be found at '{self._binary_path}'.") + self._port = port + + my_env = os.environ.copy() + my_env["RUST_BACKTRACE"] = "1" + self._subprocess = subprocess.Popen([self._binary_path, '--port', str(self._port)], + env=my_env) + # Will initialize on first use. + self._stub = None + self._shmem = None + self._shmem_size = 0 + # Try shared memory, fall back if an error occurs. + self._use_shmem = True + + def __del__(self): + self._subprocess.kill() + self._del_shmem() + + def _get_stub(self): + if self._stub is None: + channel = grpc.insecure_channel('localhost:%d' % self._port) + self._stub = cedar_detect_pb2_grpc.CedarDetectStub(channel) + return self._stub + + # Returns True if the shared memory file was re-created with a new size. + def _alloc_shmem(self, size): + resized = False + if self._shmem is not None and size > self._shmem_size: + self._shmem.close() + self._shmem.unlink() + self._shmem = None + resized = True + if self._shmem is None: + self._shmem = shared_memory.SharedMemory( + "/cedar_detect_image", create=True, size=size) + self._shmem_size = size + return resized + + def _del_shmem(self): + if self._shmem is not None: + self._shmem.close() + self._shmem.unlink() + self._shmem = None + + def extract_centroids(self, image, sigma, use_binned, binning=None, + detect_hot_pixels=True, normalize_rows=True): + """Invokes the CedarDetect.ExtractCentroids() RPC. Returns [(y,x)] of the + detected star centroids. + """ + np_image = np.asarray(image, dtype=np.uint8) + (height, width) = np_image.shape + + centroids_result = None + im = None + rpc_exception = None + retried = False + while True: + if rpc_exception is not None: + # See if subprocess exited. If so, we restart it and retry once. + returncode = self._subprocess.poll() + if returncode is None: + # Subprocess still there; just propagate the exception. + raise rpc_exception + self._logger.error('Subprocess exit code: %s' % returncode) + if retried: + # We already retried once, bail. + raise rpc_exception + retried = True + rpc_exception = None + self._logger.error('Creating new subprocess') + self._subprocess = subprocess.Popen( + [self._binary_path, '--port', str(self._port)]) + self._stub = None + + if self._use_shmem: + # Use shared memory to make the gRPC calls faster. This works only + # when the client (this program) and the CedarDetect gRPC server are + # running on the same machine. + + # The image data is passed in a shared memory object, with the gRPC + # request giving the name of the shared memory object. + resized = self._alloc_shmem(size=width*height) + # Create numpy array backed by shmem. + shimg = np.ndarray(np_image.shape, dtype=np_image.dtype, buffer=self._shmem.buf) + # Copy np_image into shimg. This is much cheaper than passing image + # over the gRPC call. + shimg[:] = np_image[:] + + im = cedar_detect_pb2.Image(width=width, height=height, + shmem_name=self._shmem.name, reopen_shmem=resized) + req = cedar_detect_pb2.CentroidsRequest( + input_image=im, sigma=sigma, return_binned=False, + binning=binning, use_binned_for_star_candidates=use_binned, + detect_hot_pixels=detect_hot_pixels, normalize_rows=normalize_rows) + try: + centroids_result = self._get_stub().ExtractCentroids(req, + wait_for_ready=True, + timeout=2) + break # Succeeded, break out of retry loop. + except grpc.RpcError as err: + if err.code() == grpc.StatusCode.INTERNAL: + self._logger.warning('RPC (with shmem) failed with: %s' % err.details()) + self._del_shmem() + self._use_shmem = False + self._logger.info('No longer using shared memory for CentroidsRequest() calls') + # Fall through to non-shmem path. + else: + self._logger.error('RPC (with shmem) failed with: %s' % err.details()) + rpc_exception = err + continue # Loop to retry logic. + + if not self._use_shmem: + # Not using shared memory. The image data is passed as part of the + # gRPC request. + im = cedar_detect_pb2.Image(width=width, height=height, + image_data=np_image.tobytes()) + req = cedar_detect_pb2.CentroidsRequest( + input_image=im, sigma=sigma, return_binned=False, + binning=binning, use_binned_for_star_candidates=use_binned, + detect_hot_pixels=detect_hot_pixels, normalize_rows=normalize_rows) + try: + centroids_result = self._get_stub().ExtractCentroids(req) + break # Succeeded, break out of retry loop. + except grpc.RpcError as err: + self._logger.error('RPC failed with: %s' % err.details()) + rpc_exception = err # Loop to retry logic. + # while True + + tetra_centroids = [] # List of (y, x). + if centroids_result is not None: + for sc in centroids_result.star_candidates: + tetra_centroids.append((sc.centroid_position.y, sc.centroid_position.x)) + return tetra_centroids diff --git a/ogscope/vendor/tetra3/cedar_detect_pb2.py b/ogscope/vendor/tetra3/cedar_detect_pb2.py new file mode 100644 index 0000000..6d7bdc4 --- /dev/null +++ b/ogscope/vendor/tetra3/cedar_detect_pb2.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: tetra3/cedar_detect.proto +# Protobuf Python Version: 5.29.0 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 5, + 29, + 0, + '', + 'tetra3/cedar_detect.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x19tetra3/cedar_detect.proto\x12\x0c\x63\x65\x64\x61r_detect\x1a\x1egoogle/protobuf/duration.proto\"\xd6\x02\n\x10\x43\x65ntroidsRequest\x12(\n\x0binput_image\x18\x01 \x01(\x0b\x32\x13.cedar_detect.Image\x12\r\n\x05sigma\x18\x02 \x01(\x01\x12\x14\n\x08max_size\x18\x03 \x01(\x05\x42\x02\x18\x01\x12\x14\n\x07\x62inning\x18\x08 \x01(\x05H\x00\x88\x01\x01\x12\x15\n\rreturn_binned\x18\x04 \x01(\x08\x12&\n\x1euse_binned_for_star_candidates\x18\x05 \x01(\x08\x12\x19\n\x11\x64\x65tect_hot_pixels\x18\x06 \x01(\x08\x12\x16\n\x0enormalize_rows\x18\t \x01(\x08\x12@\n\x1a\x65stimate_background_region\x18\x07 \x01(\x0b\x32\x17.cedar_detect.RectangleH\x01\x88\x01\x01\x42\n\n\x08_binningB\x1d\n\x1b_estimate_background_region\"N\n\tRectangle\x12\x10\n\x08origin_x\x18\x01 \x01(\x05\x12\x10\n\x08origin_y\x18\x02 \x01(\x05\x12\r\n\x05width\x18\x03 \x01(\x05\x12\x0e\n\x06height\x18\x04 \x01(\x05\"\xbe\x02\n\x0f\x43\x65ntroidsResult\x12\x16\n\x0enoise_estimate\x18\x01 \x01(\x01\x12 \n\x13\x62\x61\x63kground_estimate\x18\x07 \x01(\x01H\x00\x88\x01\x01\x12\x17\n\x0fhot_pixel_count\x18\x02 \x01(\x05\x12\x17\n\x0fpeak_star_pixel\x18\x06 \x01(\x05\x12\x33\n\x0fstar_candidates\x18\x03 \x03(\x0b\x32\x1a.cedar_detect.StarCentroid\x12.\n\x0c\x62inned_image\x18\x04 \x01(\x0b\x32\x13.cedar_detect.ImageH\x01\x88\x01\x01\x12\x31\n\x0e\x61lgorithm_time\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x16\n\x14_background_estimateB\x0f\n\r_binned_image\"x\n\x05Image\x12\r\n\x05width\x18\x01 \x01(\x05\x12\x0e\n\x06height\x18\x02 \x01(\x05\x12\x12\n\nimage_data\x18\x03 \x01(\x0c\x12\x17\n\nshmem_name\x18\x04 \x01(\tH\x00\x88\x01\x01\x12\x14\n\x0creopen_shmem\x18\x05 \x01(\x08\x42\r\n\x0b_shmem_name\"n\n\x0cStarCentroid\x12\x33\n\x11\x63\x65ntroid_position\x18\x01 \x01(\x0b\x32\x18.cedar_detect.ImageCoord\x12\x12\n\nbrightness\x18\x04 \x01(\x01\x12\x15\n\rnum_saturated\x18\x06 \x01(\x05\"\"\n\nImageCoord\x12\t\n\x01x\x18\x01 \x01(\x01\x12\t\n\x01y\x18\x02 \x01(\x01\x32`\n\x0b\x43\x65\x64\x61rDetect\x12Q\n\x10\x45xtractCentroids\x12\x1e.cedar_detect.CentroidsRequest\x1a\x1d.cedar_detect.CentroidsResultb\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'tetra3.cedar_detect_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_CENTROIDSREQUEST'].fields_by_name['max_size']._loaded_options = None + _globals['_CENTROIDSREQUEST'].fields_by_name['max_size']._serialized_options = b'\030\001' + _globals['_CENTROIDSREQUEST']._serialized_start=76 + _globals['_CENTROIDSREQUEST']._serialized_end=418 + _globals['_RECTANGLE']._serialized_start=420 + _globals['_RECTANGLE']._serialized_end=498 + _globals['_CENTROIDSRESULT']._serialized_start=501 + _globals['_CENTROIDSRESULT']._serialized_end=819 + _globals['_IMAGE']._serialized_start=821 + _globals['_IMAGE']._serialized_end=941 + _globals['_STARCENTROID']._serialized_start=943 + _globals['_STARCENTROID']._serialized_end=1053 + _globals['_IMAGECOORD']._serialized_start=1055 + _globals['_IMAGECOORD']._serialized_end=1089 + _globals['_CEDARDETECT']._serialized_start=1091 + _globals['_CEDARDETECT']._serialized_end=1187 +# @@protoc_insertion_point(module_scope) diff --git a/ogscope/vendor/tetra3/cedar_detect_pb2.pyi b/ogscope/vendor/tetra3/cedar_detect_pb2.pyi new file mode 100644 index 0000000..b1b7ed0 --- /dev/null +++ b/ogscope/vendor/tetra3/cedar_detect_pb2.pyi @@ -0,0 +1,91 @@ +from google.protobuf import duration_pb2 as _duration_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union + +DESCRIPTOR: _descriptor.FileDescriptor + +class CentroidsRequest(_message.Message): + __slots__ = ("input_image", "sigma", "max_size", "binning", "return_binned", "use_binned_for_star_candidates", "detect_hot_pixels", "normalize_rows", "estimate_background_region") + INPUT_IMAGE_FIELD_NUMBER: _ClassVar[int] + SIGMA_FIELD_NUMBER: _ClassVar[int] + MAX_SIZE_FIELD_NUMBER: _ClassVar[int] + BINNING_FIELD_NUMBER: _ClassVar[int] + RETURN_BINNED_FIELD_NUMBER: _ClassVar[int] + USE_BINNED_FOR_STAR_CANDIDATES_FIELD_NUMBER: _ClassVar[int] + DETECT_HOT_PIXELS_FIELD_NUMBER: _ClassVar[int] + NORMALIZE_ROWS_FIELD_NUMBER: _ClassVar[int] + ESTIMATE_BACKGROUND_REGION_FIELD_NUMBER: _ClassVar[int] + input_image: Image + sigma: float + max_size: int + binning: int + return_binned: bool + use_binned_for_star_candidates: bool + detect_hot_pixels: bool + normalize_rows: bool + estimate_background_region: Rectangle + def __init__(self, input_image: _Optional[_Union[Image, _Mapping]] = ..., sigma: _Optional[float] = ..., max_size: _Optional[int] = ..., binning: _Optional[int] = ..., return_binned: bool = ..., use_binned_for_star_candidates: bool = ..., detect_hot_pixels: bool = ..., normalize_rows: bool = ..., estimate_background_region: _Optional[_Union[Rectangle, _Mapping]] = ...) -> None: ... + +class Rectangle(_message.Message): + __slots__ = ("origin_x", "origin_y", "width", "height") + ORIGIN_X_FIELD_NUMBER: _ClassVar[int] + ORIGIN_Y_FIELD_NUMBER: _ClassVar[int] + WIDTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + origin_x: int + origin_y: int + width: int + height: int + def __init__(self, origin_x: _Optional[int] = ..., origin_y: _Optional[int] = ..., width: _Optional[int] = ..., height: _Optional[int] = ...) -> None: ... + +class CentroidsResult(_message.Message): + __slots__ = ("noise_estimate", "background_estimate", "hot_pixel_count", "peak_star_pixel", "star_candidates", "binned_image", "algorithm_time") + NOISE_ESTIMATE_FIELD_NUMBER: _ClassVar[int] + BACKGROUND_ESTIMATE_FIELD_NUMBER: _ClassVar[int] + HOT_PIXEL_COUNT_FIELD_NUMBER: _ClassVar[int] + PEAK_STAR_PIXEL_FIELD_NUMBER: _ClassVar[int] + STAR_CANDIDATES_FIELD_NUMBER: _ClassVar[int] + BINNED_IMAGE_FIELD_NUMBER: _ClassVar[int] + ALGORITHM_TIME_FIELD_NUMBER: _ClassVar[int] + noise_estimate: float + background_estimate: float + hot_pixel_count: int + peak_star_pixel: int + star_candidates: _containers.RepeatedCompositeFieldContainer[StarCentroid] + binned_image: Image + algorithm_time: _duration_pb2.Duration + def __init__(self, noise_estimate: _Optional[float] = ..., background_estimate: _Optional[float] = ..., hot_pixel_count: _Optional[int] = ..., peak_star_pixel: _Optional[int] = ..., star_candidates: _Optional[_Iterable[_Union[StarCentroid, _Mapping]]] = ..., binned_image: _Optional[_Union[Image, _Mapping]] = ..., algorithm_time: _Optional[_Union[_duration_pb2.Duration, _Mapping]] = ...) -> None: ... + +class Image(_message.Message): + __slots__ = ("width", "height", "image_data", "shmem_name", "reopen_shmem") + WIDTH_FIELD_NUMBER: _ClassVar[int] + HEIGHT_FIELD_NUMBER: _ClassVar[int] + IMAGE_DATA_FIELD_NUMBER: _ClassVar[int] + SHMEM_NAME_FIELD_NUMBER: _ClassVar[int] + REOPEN_SHMEM_FIELD_NUMBER: _ClassVar[int] + width: int + height: int + image_data: bytes + shmem_name: str + reopen_shmem: bool + def __init__(self, width: _Optional[int] = ..., height: _Optional[int] = ..., image_data: _Optional[bytes] = ..., shmem_name: _Optional[str] = ..., reopen_shmem: bool = ...) -> None: ... + +class StarCentroid(_message.Message): + __slots__ = ("centroid_position", "brightness", "num_saturated") + CENTROID_POSITION_FIELD_NUMBER: _ClassVar[int] + BRIGHTNESS_FIELD_NUMBER: _ClassVar[int] + NUM_SATURATED_FIELD_NUMBER: _ClassVar[int] + centroid_position: ImageCoord + brightness: float + num_saturated: int + def __init__(self, centroid_position: _Optional[_Union[ImageCoord, _Mapping]] = ..., brightness: _Optional[float] = ..., num_saturated: _Optional[int] = ...) -> None: ... + +class ImageCoord(_message.Message): + __slots__ = ("x", "y") + X_FIELD_NUMBER: _ClassVar[int] + Y_FIELD_NUMBER: _ClassVar[int] + x: float + y: float + def __init__(self, x: _Optional[float] = ..., y: _Optional[float] = ...) -> None: ... diff --git a/ogscope/vendor/tetra3/cedar_detect_pb2_grpc.py b/ogscope/vendor/tetra3/cedar_detect_pb2_grpc.py new file mode 100644 index 0000000..38dbc6c --- /dev/null +++ b/ogscope/vendor/tetra3/cedar_detect_pb2_grpc.py @@ -0,0 +1,98 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from tetra3 import cedar_detect_pb2 as tetra3_dot_cedar__detect__pb2 + +GRPC_GENERATED_VERSION = '1.71.0' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + f' but the generated code in tetra3/cedar_detect_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class CedarDetectStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ExtractCentroids = channel.unary_unary( + '/cedar_detect.CedarDetect/ExtractCentroids', + request_serializer=tetra3_dot_cedar__detect__pb2.CentroidsRequest.SerializeToString, + response_deserializer=tetra3_dot_cedar__detect__pb2.CentroidsResult.FromString, + _registered_method=True) + + +class CedarDetectServicer(object): + """Missing associated documentation comment in .proto file.""" + + def ExtractCentroids(self, request, context): + """Returns INTERNAL error if the Image request's shared memory cannot be accessed. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_CedarDetectServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ExtractCentroids': grpc.unary_unary_rpc_method_handler( + servicer.ExtractCentroids, + request_deserializer=tetra3_dot_cedar__detect__pb2.CentroidsRequest.FromString, + response_serializer=tetra3_dot_cedar__detect__pb2.CentroidsResult.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'cedar_detect.CedarDetect', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('cedar_detect.CedarDetect', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class CedarDetect(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def ExtractCentroids(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/cedar_detect.CedarDetect/ExtractCentroids', + tetra3_dot_cedar__detect__pb2.CentroidsRequest.SerializeToString, + tetra3_dot_cedar__detect__pb2.CentroidsResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/ogscope/vendor/tetra3/cli/__init__.py b/ogscope/vendor/tetra3/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ogscope/vendor/tetra3/cli/benchmark_synthetic_fovs.py b/ogscope/vendor/tetra3/cli/benchmark_synthetic_fovs.py new file mode 100644 index 0000000..3f5fb68 --- /dev/null +++ b/ogscope/vendor/tetra3/cli/benchmark_synthetic_fovs.py @@ -0,0 +1,65 @@ +""" +Enumerate test FOVs from a star catalog and evaluate Cedar's performance +solving them. + +Example: + python benchmark_synthetic_fovs.py --width 1280 --height 960 --fov_deg 12 --num_fovs 1000 +""" +import argparse + +from tetra3 import benchmark_synthetic_fovs +from pathlib import Path +from typing import List + +def _print_histo_bin(solve_time_histo: List[float], bin_width: float): + for histo_bin, val in enumerate(solve_time_histo): + if val == 0: + continue + + hv = histo_bin * bin_width + if histo_bin < len(solve_time_histo) - 1: + print(f'{hv}-{(histo_bin + 1) * bin_width}ms: {val}') + else: + print(f'>= {hv}ms: {val}') + + +def main(): + parser = argparse.ArgumentParser(description="Synthesize FOVs and test Cedar-solve") + + # required flags + parser.add_argument("--width", type=int, required=True, + help="Width (in pixels) of image sensor.") + parser.add_argument("--height", type=int, required=True, + help="Height (in pixels) of image sensor.") + parser.add_argument("--fov_deg", type=float, required=True, + help="Horizontal field of view (in degrees) of image.") + parser.add_argument("--num_fovs", type=int, required=True, + help="Number of FOVs to synthesize (2N + 1 actually generated).") + + # optional flags + parser.add_argument("--num_centroids", type=int, default=20, + help="Maximum number of centroids to pass to solver.") + parser.add_argument("--database", type=Path, default='default_database', + help="Pattern database to load.") + + args = parser.parse_args() + + result = benchmark_synthetic_fovs.benchmark_synthetic_fovs( + args.width, args.height, args.fov_deg, args.num_fovs, args.num_centroids, + database=args.database) + + num_failures = result['num_failures'] + num_successes = result['num_successes'] + mean_solve_time_ms = result['mean_solve_time_ms'] + max_solve_time_ms = result['max_solve_time_ms'] + print( + 'Results - ' + f'num_failures: {num_failures} ' + f'mean_solve_time_ms: {mean_solve_time_ms:.1f} ' + f'max_solve_time_ms: {max_solve_time_ms}' + ) + _print_histo_bin(result['solve_time_histo'], result['histo_bin_width_ms']) + + +if __name__ == "__main__": + main() diff --git a/ogscope/vendor/tetra3/cli/generate_database.py b/ogscope/vendor/tetra3/cli/generate_database.py new file mode 100644 index 0000000..3851540 --- /dev/null +++ b/ogscope/vendor/tetra3/cli/generate_database.py @@ -0,0 +1,95 @@ +""" +Generate a database file from a star-catalog. +Provide any argument from Tetra3.generate_database() + +Example: + tetra3-gen-db --max-fov 30 path/to/database/tyc_main path/to/target.npz +""" +import argparse +from pathlib import Path +from typing import Callable, Tuple, Union + +import tetra3 + + +def _tuple_type(type_: type) -> Callable[[str], Tuple]: + def _fn(value: str) -> Tuple: + string = value.lstrip("(").rstrip(")") + return tuple(type_(s.strip()) for s in string.split(",")) + return _fn + + +def _epoch_type(value: str) -> Union[float, str, None]: + if not value or value.lower() == 'none': + return None + if value.lower() == 'now': + return 'now' + return float(value) + + +def main(): + parser = argparse.ArgumentParser(description="Generate star pattern database") + + # positional arguments + parser.add_argument("STAR_CATALOG", type=Path, help="Star catalog file to load") + parser.add_argument("SAVE_AS", type=Path, help="File location to save the database") + + # required flags + parser.add_argument("--max-fov", type=float, required=True, + help="Maximum angle (in degrees) between stars in the same pattern.") + + # optional flags + parser.add_argument("--min-fov", type=float, + help="Minimum FOV considered when the catalogue density is trimmed to size.") + parser.add_argument("--lattice-field-oversampling", type=int, default=100, + help="When uniformly distributing pattern generation fields over the " + "celestial sphere, this determines the overlap factor.") + parser.add_argument("--patterns-per-lattice-field", type=int, default=50, + help="The number of patterns generated for each lattice field. " + "Typical values are 20 to 100.") + parser.add_argument("--verification-stars-per-fov", type=int, default=150, + help="Target number of stars used for generating patterns in each FOV region. " + "Also used to limit the number of stars considered for matching in " + "solve images. Typical values are large.") + parser.add_argument("--star-max-magnitude", type=float, + help="Dimmest apparent magnitude of stars retained from star catalog. " + "When not specified causes the limiting magnitude to be computed based on " + "`min_fov` and `verification_stars_per_fov`.") + parser.add_argument("--pattern-max-error", type=float, default=0.001, + help="This value determines the number of bins into which a pattern hash's " + "edge ratios are each quantized: `pattern_bins = 0.25 / pattern_max_error` " + "Default 0.001, corresponding to pattern_bins=250. For a database with " + "limiting magnitude 7, this yields a reasonable pattern hash collision rate.") + parser.add_argument("--multiscale-step", type=float, default=1.5, + help="Determines the largest ratio between subsequent FOVs that is allowed " + "when generating a multiscale database. If the ratio max_fov/min_fov " + "is less than sqrt(multiscale_step) a single scale database is built.") + parser.add_argument("--epoch-proper-motion", type=_epoch_type, default='now', + help="Determines the end year to which stellar proper motions are propagated. " + "If 'now' (default), the current year is used. If 'none', star motions " + "are not propagated and this allows catalogue entries without proper " + "motions to be used in the database.") + parser.add_argument("--linear-probe", type=bool, default=False, + help="Determines whether the pattern hash table uses quadratic probing " + "(False) or linear probing (True).") + + args = parser.parse_args() + + t3 = tetra3.Tetra3(load_database=None) + t3.generate_database( + star_catalog=args.STAR_CATALOG, + save_as=args.SAVE_AS, + max_fov=args.max_fov, + min_fov=args.min_fov, + lattice_field_oversampling=args.lattice_field_oversampling, + patterns_per_lattice_field=args.patterns_per_lattice_field, + verification_stars_per_fov=args.verification_stars_per_fov, + star_max_magnitude=args.star_max_magnitude, + pattern_max_error=args.pattern_max_error, + multiscale_step=args.multiscale_step, + epoch_proper_motion=args.epoch_proper_motion, + linear_probe=args.linear_probe, + ) + +if __name__ == "__main__": + main() diff --git a/ogscope/vendor/tetra3/data/README.md b/ogscope/vendor/tetra3/data/README.md new file mode 100644 index 0000000..457bb81 --- /dev/null +++ b/ogscope/vendor/tetra3/data/README.md @@ -0,0 +1,7 @@ +# Tetra3 图案库 / Pattern database + +将 `default_database.npz` 放到本目录,或放到项目 `data/plate_solve/`,或通过 `OGSCOPE_SOLVER_TETRA_DATABASE_PATH` 指向绝对路径。 + +可从本机已下载的 [cedar-solve](https://github.com/smroid/cedar-solve) 源码中复制 `tetra3/data/default_database.npz`,或运行 `tetra3-gen-db`(若已安装 cedar-solve)生成。 + +Place `default_database.npz` here, or under `data/plate_solve/`, or set `OGSCOPE_SOLVER_TETRA_DATABASE_PATH`. Copy from cedar-solve `tetra3/data/` or generate with `tetra3-gen-db`. diff --git a/ogscope/vendor/tetra3/fov_util.py b/ogscope/vendor/tetra3/fov_util.py new file mode 100644 index 0000000..0b152e7 --- /dev/null +++ b/ogscope/vendor/tetra3/fov_util.py @@ -0,0 +1,33 @@ +import math +import numpy as np + +def separation_for_density(fov, stars_per_fov): + """Compute minimum separation, in same units as 'fov', for achieving the desired star + density. + fov: horizontal field of view. + stars_per_fov: desired number of stars in field of view + """ + return .6 * fov / np.sqrt(stars_per_fov) + +def num_fields_for_sky(fov): + """For a given square field of view (in radians), computes how many such + fields of view are needed to cover the entire sky, by area. + """ + return math.ceil(4 * math.pi / (fov * fov)) + +def fibonacci_sphere_lattice(n): + """Yields the 2*n+1 points of a Fibonacci lattice of the sphere. + Returned points are (x, y, z) unit vectors. + See "Measurement of areas on a sphere using Fibonacci and + latitude-longitude lattices" by Alvaro Gonzalez, at + https://arxiv.org/pdf/0912.4540.pdf. + """ + phi = (1 + math.sqrt(5)) / 2 # Golden ratio ~1.618 + golden_angle_incr = 2 * math.pi * (1 - 1 / phi) # radians + for i in range(-n, n+1): + z = i / (n + 0.5) # Ranges over (-1..1). + radius = math.sqrt(1 - z * z) # Distance from axis at z. + theta = golden_angle_incr * i + x = math.cos(theta) * radius + y = math.sin(theta) * radius + yield (x, y, z) diff --git a/ogscope/vendor/tetra3/proto/cedar_detect.proto b/ogscope/vendor/tetra3/proto/cedar_detect.proto new file mode 120000 index 0000000..dfa0615 --- /dev/null +++ b/ogscope/vendor/tetra3/proto/cedar_detect.proto @@ -0,0 +1 @@ +../../../cedar-detect/src/proto/cedar_detect.proto \ No newline at end of file diff --git a/ogscope/vendor/tetra3/tetra3.py b/ogscope/vendor/tetra3/tetra3.py new file mode 100644 index 0000000..9a0c53c --- /dev/null +++ b/ogscope/vendor/tetra3/tetra3.py @@ -0,0 +1,2863 @@ +""" +tetra3: A fast lost-in-space plate solver for star trackers. +============================================================ + +Use it to identify stars in images and get the corresponding direction (i.e. right ascension and +declination) in the sky which the camera points to. The only thing tetra3 needs to know is the +approximate field of view of your camera. + +tetra3 also includes a versatile function to find spot centroids and statistics. +Alternately, you can also use another star detection/centroiding library in conjunction +with tetra3 plate solving. Cedar Detect (https://github.com/smroid/cedar-detect) is a high +performance solution for this; see cedar_detect_client.py for a way to use tetra3 with +Cedar Detect. + +Included in the package: + + - :class:`tetra3.Tetra3`: Class to solve images and load/create databases. + - :meth:`tetra3.get_centroids_from_image`: Extract spot centroids from an image. + - :meth:`tetra3.crop_and_downsample_image`: Crop and/or downsample an image. + +The class :class:`tetra3.Tetra3` has three main methods for solving images: + + - :meth:`Tetra3.solve_from_image`: Solve the camera pointing direction of an image. + - :meth:`Tetra3.solve_from_centroids`: As above, but from a list of star centroids. + - :meth:`Tetra3.generate_database`: Create a new database for your application. + +A default database (named `default_database`) is included in the repo, it is built for a field of +view range of 10 to 30 degrees with stars up to magnitude 8. + +Note: + If you wish to build you own database (typically for a different field-of-view) you must + download a star catalogue. tetra3 supports three options: + + * The 285KB Yale Bright Star Catalog 'BSC5' containing 9,110 stars. This is complete to + to about magnitude seven and is sufficient for >20 deg field-of-view setups. + * The 51MB Hipparcos Catalogue 'hip_main' containing 118,218 stars. This contains about + three stars per square degree and is sufficient down to about >10 deg field-of-view. + * The 355MB Tycho Catalogue 'tyc_main' (also from the Hipparcos satellite mission) + containing 1,058,332 stars, around 25 per square degree. This is complete to + magnitude 10 and is sufficient down to about >3 deg field-of-view. + + The 'BSC5' data is avaiable from (use + byte format file) and 'hip_main' and 'tyc_main' are available from + (save the appropriate .dat file). The + downloaded catalogue must be placed in the tetra3/tetra3 directory. + +Cedar Solve is Free and Open-Source Software based on `Tetra` rewritten by Gustav +Pettersson at ESA, with further improvements by Steven Rosenthal. + +The original software is due to: +J. Brown, K. Stubis, and K. Cahoy, "TETRA: Star Identification with Hash Tables", +Proceedings of the AIAA/USU Conference on Small Satellites, 2017. + + + +Cedar Solve license: + Copyright 2023 Steven Rosenthal smr@dt3.org + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +tetra3 license: + Copyright 2019 the European Space Agency + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +Original Tetra license notice: + Copyright (c) 2016 brownj4 + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +""" + +# Standard imports: +from pathlib import Path +import csv +import logging +import math +import itertools +from time import perf_counter as precision_timestamp +from datetime import datetime +from numbers import Number +from collections import OrderedDict + +# External imports: +import numpy as np +from numpy.linalg import norm, lstsq +import scipy.ndimage +import scipy.optimize +import scipy.stats +import scipy +from scipy.spatial import KDTree +from scipy.spatial.distance import pdist, cdist + +from PIL import Image, ImageDraw + +# Local imports. +from tetra3.breadth_first_combinations import breadth_first_combinations +from tetra3.fov_util import fibonacci_sphere_lattice, num_fields_for_sky, separation_for_density + +# Status codes returned by solve_from_image() and solve_from_centroids() +MATCH_FOUND = 1 +NO_MATCH = 2 +TIMEOUT = 3 +CANCELLED = 4 +TOO_FEW = 5 + +_MAGIC_RAND = np.uint64(2654435761) +_supported_databases = ('bsc5', 'hip_main', 'tyc_main') +_lib_root = Path(__file__).parent + +def _is_prime(n): + if n < 2: + return False + if n == 2: + return True + if n % 2 == 0: + return False + # Only check odd numbers up to sqrt(n) + for i in range(3, int(n ** 0.5) + 1, 2): + if n % i == 0: + return False + return True + +def _next_prime(n): + if n < 2: + return 2 + n = n + 1 + (n % 2) # Next odd number after n + while not _is_prime(n): + n += 2 # Skip even numbers + return n + +def _insert_at_index(pattern, hash_index, table, linear_probe): + """Inserts to table with quadratic or linear probing. Returns table index where + pattern was inserted.""" + max_ind = np.uint64(table.shape[0]) + hash_index = np.uint64(hash_index) + for c in itertools.count(): + c = np.uint64(c) + if linear_probe: + i = (hash_index + c) % max_ind + else: + i = (hash_index + c*c) % max_ind + if all(table[i, :] == 0): + table[i, :] = pattern + return i + +def _get_table_indices_from_hash(hash_index, table, linear_probe): + """Gets from table with quadratic or linear probing, returns list of all + possibly matching indices.""" + max_ind = np.uint64(table.shape[0]) + hash_index = np.uint64(hash_index) + found = [] + for c in itertools.count(): + c = np.uint64(c) + if linear_probe: + i = (hash_index + c) % max_ind + else: + i = (hash_index + c*c) % max_ind + if all(table[i, :] == 0): + return np.array(found) + else: + found.append(i) + +def _compute_pattern_key_hash(pattern_key, bin_factor): + """Computes a 64 bit hash for a given pattern_key (tuple of ordered binned edge + ratios). Can be length p list or n by p array. + """ + pattern_key = np.uint64(pattern_key) + bin_factor = np.uint64(bin_factor) + # If p is the length of the pattern_key (default 5) and B is the number of bins + # (default 50, calculated from max error), this will first give each pattern_key + # a unique index from 0 to B^p-1. + if pattern_key.ndim == 1: + return np.sum(pattern_key*bin_factor**np.arange(len(pattern_key), + dtype=np.uint64), + dtype=np.uint64) + else: + return np.sum(pattern_key*bin_factor**np.arange(pattern_key.shape[1], + dtype=np.uint64)[None, :], + axis=1, dtype=np.uint64) + +def _pattern_key_hash_to_index(pattern_key_hash, max_index, linear_probe): + """Get hash index for a given pattern key hash. + """ + max_index = np.uint64(max_index) + if linear_probe: + return pattern_key_hash % max_index + else: + # For legacy compability. + with np.errstate(over='ignore'): + return (pattern_key_hash*_MAGIC_RAND) % max_index + +def _compute_vectors(centroids, size, fov): + """Get unit vectors from star centroids (pinhole camera).""" + # compute list of (i,j,k) vectors given list of (y,x) star centroids and + # an estimate of the image's field-of-view in the x dimension + # by applying the pinhole camera equations + centroids = np.array(centroids, dtype=np.float32) + (height, width) = size[:2] + scale_factor = np.tan(fov/2)/width*2 + star_vectors = np.ones((len(centroids), 3)) + # Pixel centre of image + img_center = [height/2, width/2] + # Calculate normal vectors + star_vectors[:, 2:0:-1] = (img_center - centroids) * scale_factor + star_vectors = star_vectors / norm(star_vectors, axis=1)[:, None] + return star_vectors + +def _compute_centroids(vectors, size, fov): + """Get (undistorted) centroids from a set of (derotated) unit vectors + vectors: Nx3 of (i,j,k) where i is boresight, j is x (horizontal) + size: (height, width) in pixels. + fov: horizontal field of view in radians. + We return all centroids plus a list of centroids indices that are within + the field of view. + """ + (height, width) = size[:2] + scale_factor = -width/2/np.tan(fov/2) + centroids = scale_factor*vectors[:, 2:0:-1]/vectors[:, [0]] + centroids += [height/2, width/2] + keep = np.flatnonzero(np.logical_and( + np.all(centroids > [0, 0], axis=1), + np.all(centroids < [height, width], axis=1))) + return (centroids, keep) + +def _undistort_centroids(centroids, size, k): + """Apply r_u = r_d(1 - k'*r_d^2)/(1 - k) undistortion, where k'=k*(2/width)^2, + i.e. k is the distortion that applies width/2 away from the centre. + centroids: Nx2 pixel coordinates (y, x), (0.5, 0.5) top left pixel centre. + size: (height, width) in pixels. + k: distortion, negative is barrel, positive is pincushion + """ + centroids = np.array(centroids, dtype=np.float32) + (height, width) = size[:2] + kp = k*(2/width)**2 # k prime + # Centre + centroids -= [height/2, width/2] + r_dist = norm(centroids, axis=1) + # Scale + scale = (1 - kp*r_dist**2)/(1 - k) + centroids *= scale[:, None] + # Decentre + centroids += [height/2, width/2] + return centroids + +def _distort_centroids(centroids, size, k, tol=1e-6, maxiter=30): + """Distort centroids corresponding to r_u = r_d(1 - k'*r_d^2)/(1 - k), + where k'=k*(2/width)^2 i.e. k is the distortion that applies + width/2 away from the centre. + + Iterates with Newton-Raphson until the step is smaller than tol + or maxiter iterations have been exhausted. + """ + centroids = np.array(centroids, dtype=np.float32) + (height, width) = size[:2] + kp = k*(2/width)**2 # k prime + # Centre + centroids -= [height/2, width/2] + r_undist = norm(centroids, axis=1) + # Initial distorted guess, undistorted are the same position + r_dist = r_undist.copy() + for i in range(maxiter): + r_undist_est = r_dist*(1 - kp*r_dist**2)/(1 - k) + dru_drd = (1 - 2*kp*r_dist)/(1 - k) + error = r_undist - r_undist_est + r_dist += error/dru_drd + if np.all(np.abs(error) < tol): + break + centroids *= (r_dist/r_undist)[:, None] + centroids += [height/2, width/2] + return centroids + +def _find_rotation_matrix(image_vectors, catalog_vectors): + """Calculate the least squares best rotation matrix between the two sets of vectors. + image_vectors and catalog_vectors both Nx3. Must be ordered as matching pairs. + """ + # find the covariance matrix H between the image and catalog vectors + H = np.dot(image_vectors.T, catalog_vectors) + # use singular value decomposition to find the rotation matrix + (U, S, V) = np.linalg.svd(H) + return np.dot(U, V) + +def _find_centroid_matches(image_centroids, catalog_centroids, r): + """Find matching pairs, unique and within radius r. + image_centroids: Nx2 (y, x) in pixels + catalog_centroids: Mx2 (y, x) in pixels + r: radius in pixels + + returns Kx2 list of matches, first column is index in image_centroids, + second column is index in catalog_centroids + """ + dists = cdist(image_centroids, catalog_centroids) + matches = np.argwhere(dists < r) + # Make sure we only have unique 1-1 matches + matches = matches[np.unique(matches[:, 1], return_index=True)[1], :] + matches = matches[np.unique(matches[:, 0], return_index=True)[1], :] + return matches + +def _angle_from_distance(dist): + """Given a euclidean distance between two points on the unit sphere, + return the center angle (in radians) between the two points. + """ + return 2.0 * np.arcsin(0.5 * dist) + +def _distance_from_angle(angle): + """Return the euclidean distance between two points on the unit sphere with the + given center angle (in radians). + """ + return 2.0 * np.sin(angle / 2.0) + + +class Tetra3(): + """Solve star patterns and manage databases. + + To find the direction in the sky an image is showing, this class calculates the + geometric keys of star patterns seen in the image and looks for matching keys in a + pattern database loaded into memory. Subsequently, all stars that should be visible in + the image (based on the database pattern's location) are looked for and the match is + confirmed or rejected based on the probability that the found number of matches + happens by chance. + + Each pattern is made up of four stars, and the pattern key is created by calculating + the distances between every pair of stars in the pattern and normalising by the + longest to create a set of five numbers between zero and one. This information, and + the desired tolerance, is used to find the indices in the database where the match may + reside by a table index hashing function. See the description of + :meth:`generate_database` for more detail. + + A database needs to be generated with patterns which are of appropriate scale for the + horizontal field of view (FOV) of your camera. Therefore, generate a database using + :meth:`generate_database` with a `max_fov` which is the FOV of your camera (or + slightly larger). A database with `max_fov=30` (degrees) is included as + `default_database.npz`. + + Star locations (centroids) are found using :meth:`tetra3.get_centroids_from_image`, + use one of your images to find settings which work well for your images. Then pass + those settings as keyword arguments to :meth:`solve_from_image`. Alternately, you can + use Cedar Detect for detecting and centroiding stars in your images. + + Example 1: Load database and solve image + :: + + import tetra3 + # Create instance, automatically loads the default database + t3 = tetra3.Tetra3() + # Solve for image (PIL.Image), with some optional arguments + result = t3.solve_from_image(image, fov_estimate=11, fov_max_error=.5, max_area=300) + + Example 2: Generate and save database + :: + + import tetra3 + # Create instance without loading any database + t3 = tetra3.Tetra3(load_database=None) + # Generate and save database + t3.generate_database(max_fov=20, save_as='my_database_name') + + Args: + load_database (str or pathlib.Path, optional): Database to load. Will call + :meth:`load_database` with the provided argument after creating instance. Defaults to + 'default_database'. Can set to None to create Tetra3 object without loaded database. + debug_folder (pathlib.Path, optional): The folder for debug logging. If None (the default) + debug logging will be disabled unless handlers have been added to the `tetra3.Tetra3` + logger before creating the insance. + + """ + def __init__(self, load_database='default_database', debug_folder=None): + # Logger setup + self._debug_folder = None + self._logger = logging.getLogger('tetra3.Tetra3') + if not self._logger.hasHandlers(): + # Add new handlers to the logger if there are none + self._logger.setLevel(logging.DEBUG) + # Console handler at INFO level + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + # Format and add + formatter = logging.Formatter('%(asctime)s:%(name)s-%(levelname)s: %(message)s') + ch.setFormatter(formatter) + self._logger.addHandler(ch) + if debug_folder is not None: + self.debug_folder = debug_folder + # File handler at DEBUG level + fh = logging.FileHandler(self.debug_folder / 'tetra3.txt') + fh.setLevel(logging.DEBUG) + fh.setFormatter(formatter) + self._logger.addHandler(fh) + + self._logger.debug('Tetra3 Constructor called with load_database=' + str(load_database)) + self._star_table = None + self._star_kd_tree = None + self._star_catalog_IDs = None + self._pattern_catalog = None + self._num_patterns = None + self._pattern_largest_edge = None + self._pattern_key_hashes = None + self._verification_catalog = None + self._cancelled = False + + self._db_props = {'pattern_mode': None, 'hash_table_type': None, + 'pattern_size': None, 'pattern_bins': None, 'pattern_max_error': None, + 'max_fov': None, 'min_fov': None, 'star_catalog': None, + 'epoch_equinox': None, 'epoch_proper_motion': None, + 'lattice_field_oversampling': None, 'patterns_per_lattice_field': None, + 'verification_stars_per_fov': None, 'star_max_magnitude': None, + 'range_ra': None, 'range_dec': None, 'presort_patterns': None, + 'num_patterns': None} + + if load_database is not None: + self._logger.debug('Trying to load database') + self.load_database(load_database) + + @property + def debug_folder(self): + """pathlib.Path: Get or set the path for debug logging. Will create folder if not existing. + """ + return self._debug_folder + + @debug_folder.setter + def debug_folder(self, path): + # Do not do logging in here! This will be called before the logger is set up + assert isinstance(path, Path), 'Must be pathlib.Path object' + if path.is_file(): + path = path.parent + if not path.is_dir(): + path.mkdir(parents=True) + self._debug_folder = path + + @property + def has_database(self): + """bool: True if a database is loaded.""" + return not (self._star_table is None or self._pattern_catalog is None) + + @property + def star_table(self): + """numpy.ndarray: Table of stars in the database. + + The table is an array with six columns: + - Right ascension (radians) + - Declination (radians) + - x = cos(ra) * cos(dec) + - y = sin(ra) * cos(dec) + - z = sin(dec) + - Apparent magnitude + """ + return self._star_table + + @property + def star_kd_tree(self): + """KDTree: KD tree of stars in the database. + """ + return self._star_kd_tree + + @property + def pattern_catalog(self): + """numpy.ndarray: Catalog of patterns in the database.""" + return self._pattern_catalog + + @property + def num_patterns(self): + """numpy.uint32: Number of patterns in the database.""" + return self._num_patterns + + @property + def pattern_largest_edge(self): + """numpy.ndarray: Catalog of largest edges for each pattern in milliradian.""" + return self._pattern_largest_edge + + @property + def pattern_key_hashes(self): + """numpy.ndarray: Catalog of pattern key hashes for each pattern in the + database.""" + return self._pattern_key_hashes + + @property + def star_catalog_IDs(self): + """numpy.ndarray: Table of catalogue IDs for each entry in the star table. + + The table takes different format depending on the source catalogue used + to build the database. See the `star_catalog` key of + :meth:`database_properties` to find the source catalogue. + - bsc5: A numpy array of size (N,) with datatype uint16. Stores the 'BSC' number. + - hip_main: A numpy array of size (N,) with datatype uint32. Stores the 'HIP' number. + - tyc_main: A numpy array of size (N, 3) with datatype uint16. Stores the + (TYC1, TYC2, TYC3) numbers. + + Is None if no database is loaded or an older database without IDs stored. + """ + return self._star_catalog_IDs + + @property + def database_properties(self): + """dict: Dictionary of database properties. + + Keys: + - 'pattern_mode': Method used to identify star patterns. Is always 'edge_ratio'. + - 'hash_table_type': What algorithm is used for the pattern hash table. The only + values (currently) are 'quadratic_probe' and 'linear_probe'. + - 'pattern_size': Number of stars in each pattern. + - 'pattern_bins': Number of bins per dimension in pattern catalog. + - 'pattern_max_error': Maximum difference allowed in pattern for a match. + - 'max_fov': Maximum camera horizontal field of view (in degrees) the database is + built for. This will also be the angular extent of the largest pattern. + - 'min_fov': Minimum camera horizontal field of view (in degrees) the database is + built for. This drives the density of stars in the database, patterns may be + smaller than this. + - 'lattice_field_oversampling': When uniformly distributing pattern generation fields over + the celestial sphere, this determines the overlap factor. + Also stored as 'pattern_stars_per_fov' for compatibility with earlier versions. + - 'patterns_per_lattice_field': Number of patterns generated for each lattice field. + Also stored as 'pattern_stars_per_anchor_star' for compatibility with earlier versions. + - 'verification_stars_per_fov': Number of stars in solve-time FOV to retain. + - 'star_max_magnitude': Dimmest apparent magnitude of stars in database. + - 'star_catalog': Name of the star catalog (e.g. bcs5, hip_main, tyc_main) the database was + built from. Returns 'unknown' for old databases where this data was not saved. + - 'epoch_equinox': Epoch of the 'star_catalog' celestial coordinate system. Usually 2000, + but could be 1950 for old Bright Star Catalog versions. + - 'epoch_proper_motion': year to which stellar proper motions have been propagated. + - 'presort_patterns': Indicates if the pattern indices are sorted by distance to the + centroid. + - 'range_ra': Always None, no longer used. The whole sky is included in the database. + - 'range_dec': Always None, no longer used. The whole sky is included in the database. + - 'num_patterns': The number of patterns in the database. If None, this is one + half of the pattern table size. + """ + return self._db_props + + def load_database(self, path='default_database'): + """Load database from file. + + Args: + path (str or pathlib.Path): The file to load. If given a str, the file will be looked + for in the tetra3/data directory. If given a pathlib.Path, this path will be used + unmodified. The suffix .npz will be added. + """ + self._logger.debug('Got load database with: ' + str(path)) + if isinstance(path, str): + self._logger.debug('String given, append to tetra3 directory') + path = (Path(__file__).parent / 'data' / path).with_suffix('.npz') + else: + self._logger.debug('Not a string, use as path directly') + path = Path(path).with_suffix('.npz') + + self._logger.info('Loading database from: ' + str(path)) + # NumPy 2+ 默认禁止 unpickle;官方 .npz 含 object 数组时需显式允许 / NumPy 2+ blocks + # unpickling by default; upstream Tetra3 DB may contain object arrays. + with np.load(path, allow_pickle=True) as data: + self._logger.debug('Loaded database, unpack files') + self._pattern_catalog = data['pattern_catalog'] + + self._star_table = data['star_table'] + # Insert all stars in a KD-tree for fast neighbour lookup + all_star_vectors = self._star_table[:, 2:5] + self._star_kd_tree = KDTree(all_star_vectors) + + props_packed = data['props_packed'] + try: + self._pattern_largest_edge = data['pattern_largest_edge'] + except KeyError: + self._logger.debug('Database does not have largest edge stored, set to None.') + self._pattern_largest_edge = None + try: + self._pattern_key_hashes = data['pattern_key_hashes'] + except KeyError: + self._logger.debug('Database does not have pattern key hashes stored, set to None.') + self._pattern_key_hashes = None + try: + self._star_catalog_IDs = data['star_catalog_IDs'] + except KeyError: + self._logger.debug('Database does not have catalogue IDs stored, set to None.') + self._star_catalog_IDs = None + + self._logger.debug('Unpacking properties') + for key in self._db_props.keys(): + try: + self._db_props[key] = props_packed[key][()] + self._logger.debug('Unpacked ' + str(key)+' to: ' + str(self._db_props[key])) + except ValueError: + if key == 'verification_stars_per_fov': + self._db_props[key] = props_packed['catalog_stars_per_fov'][()] + self._logger.debug('Unpacked catalog_stars_per_fov to: ' \ + + str(self._db_props[key])) + elif key == 'star_max_magnitude': + self._db_props[key] = props_packed['star_min_magnitude'][()] + self._logger.debug('Unpacked star_min_magnitude to: ' \ + + str(self._db_props[key])) + elif key == 'presort_patterns': + self._db_props[key] = False + self._logger.debug('No presort_patterns key, set to False') + elif key == 'star_catalog': + self._db_props[key] = 'unknown' + self._logger.debug('No star_catalog key, set to unknown') + elif key == 'num_patterns': + self._db_props[key] = self.pattern_catalog.shape[0] // 2 + self._logger.debug('No num_patterns key, set to half of pattern_catalog size') + else: + self._db_props[key] = None + self._logger.warning('Missing key in database (likely version difference): %s' + % str(key)) + if self._db_props['min_fov'] is None: + self._logger.debug('No min_fov key, copy from max_fov') + self._db_props['min_fov'] = self._db_props['max_fov'] + self._num_patterns = self._db_props['num_patterns'] + self._logger.debug('Database properties %s' % self._db_props) + + + def save_database(self, path): + """Save database to file. + + Args: + path (str or pathlib.Path): The file to save to. If given a str, the file will be saved + in the tetra3/data directory. If given a pathlib.Path, this path will be used + unmodified. The suffix .npz will be added. + """ + assert self.has_database, 'No database' + self._logger.debug('Got save database with: ' + str(path)) + if isinstance(path, str): + self._logger.debug('String given, append to tetra3 directory') + path = (Path(__file__).parent / 'data' / path).with_suffix('.npz') + else: + self._logger.debug('Not a string, use as path directly') + path = Path(path).with_suffix('.npz') + + self._logger.info('Saving database to: ' + str(path)) + + # Pack properties as numpy structured array + props_packed = np.array((self._db_props['pattern_mode'], + self._db_props['hash_table_type'], + self._db_props['pattern_size'], + self._db_props['pattern_bins'], + self._db_props['pattern_max_error'], + self._db_props['max_fov'], + self._db_props['min_fov'], + self._db_props['star_catalog'], + self._db_props['epoch_equinox'], + self._db_props['epoch_proper_motion'], + self._db_props['lattice_field_oversampling'], + self._db_props['anchor_stars_per_fov'], # legacy + self._db_props['pattern_stars_per_fov'], # legacy + self._db_props['patterns_per_lattice_field'], + self._db_props['patterns_per_anchor_star'], # legacy + self._db_props['verification_stars_per_fov'], + self._db_props['star_max_magnitude'], + self._db_props['simplify_pattern'], # legacy + self._db_props['range_ra'], + self._db_props['range_dec'], + self._db_props['presort_patterns'], + self._db_props['num_patterns']), + dtype=[('pattern_mode', 'U64'), + ('hash_table_type', 'U64'), + ('pattern_size', np.uint16), + ('pattern_bins', np.uint16), + ('pattern_max_error', np.float32), + ('max_fov', np.float32), + ('min_fov', np.float32), + ('star_catalog', 'U64'), + ('epoch_equinox', np.uint16), + ('epoch_proper_motion', np.float32), + ('lattice_field_oversampling', np.uint16), + ('anchor_stars_per_fov', np.uint16), + ('pattern_stars_per_fov', np.uint16), + ('patterns_per_lattice_field', np.uint16), + ('patterns_per_anchor_star', np.uint16), + ('verification_stars_per_fov', np.uint16), + ('star_max_magnitude', np.float32), + ('simplify_pattern', bool), + ('range_ra', np.float32, (2,)), + ('range_dec', np.float32, (2,)), + ('presort_patterns', bool), + ('num_patterns', np.uint32)]) + + self._logger.debug('Packed properties into: ' + str(props_packed)) + self._logger.debug('Saving as compressed numpy archive') + + to_save = {'star_table': self.star_table, + 'pattern_catalog': self.pattern_catalog, + 'props_packed': props_packed} + if self.pattern_largest_edge is not None: + to_save['pattern_largest_edge'] = self.pattern_largest_edge + if self.pattern_key_hashes is not None: + to_save['pattern_key_hashes'] = self.pattern_key_hashes + if self.star_catalog_IDs is not None: + to_save['star_catalog_IDs'] = self.star_catalog_IDs + + np.savez_compressed(path, **to_save) + + @staticmethod + def _load_catalog(star_catalog, catalog_file_full_pathname, epoch_proper_motion, logger): + """Loads the star catalog and returns at tuple of: + star_table: an array of [ra, dec, 0, 0, 0, mag] + star_catID: array of catalog IDs for the entries in star_table + epoch_equinox: the epoch of the star catalog's celestial coordinate system. + """ + + # Calculate number of star catalog entries: + if star_catalog == 'bsc5': + # See http://tdc-www.harvard.edu/catalogs/catalogsb.html + bsc5_header_type = [('STAR0', np.int32), ('STAR1', np.int32), + ('STARN', np.int32), ('STNUM', np.int32), + ('MPROP', np.int32), ('NMAG', np.int32), + ('NBENT', np.int32)] + reader = np.fromfile(catalog_file_full_pathname, dtype=bsc5_header_type, count=1) + entry = reader[0] + num_entries = entry[2] + header_length = reader.itemsize + if num_entries > 0: + epoch_equinox = 1950 + pm_origin = 1950 # this is an assumption, not specified in bsc5 docs + else: + num_entries = -num_entries + epoch_equinox = 2000 + pm_origin = 2000 # this is an assumption, not specified in bsc5 docs + # Check that the catalogue version has the data we need + stnum = entry[3] + if stnum != 1: + logger.warning('Catalogue %s has unexpected "stnum" header value: %s' % + (star_catalog, stnum)) + mprop = entry[4] + if mprop != 1: + logger.warning('Catalogue %s has unexpected "mprop" header value: %s' % + (star_catalog, mprop)) + nmag = entry[5] + if nmag != 1: + logger.warning('Catalogue %s has unexpected "nmag" header value: %s' % + (star_catalog, nmag)) + nbent = entry[6] + if nbent != 32: + logger.warning('Catalogue %s has unexpected "nbent" header value: %s' % + (star_catalog, nbent)) + elif star_catalog in ('hip_main', 'tyc_main'): + num_entries = sum(1 for _ in open(catalog_file_full_pathname)) + epoch_equinox = 2000 + pm_origin = 1991.25 + + logger.info('Loading catalogue %s with %s star entries.' % + (star_catalog, num_entries)) + + if epoch_proper_motion is None: + # If pm propagation was disabled, set end date to origin + epoch_proper_motion = pm_origin + logger.info('Using catalog RA/Dec %s epoch; not propagating proper motions from %s.' % + (epoch_equinox, pm_origin)) + else: + logger.info('Using catalog RA/Dec %s epoch; propagating proper motions from %s to %s.' % + (epoch_equinox, pm_origin, epoch_proper_motion)) + + # Preallocate star table: elements are [ra, dec, x, y, z, mag]. + star_table = np.zeros((num_entries, 6), dtype=np.float32) + # Preallocate ID table + if star_catalog == 'bsc5': + star_catID = np.zeros(num_entries, dtype=np.uint16) + elif star_catalog == 'hip_main': + star_catID = np.zeros(num_entries, dtype=np.uint32) + else: # is tyc_main + star_catID = np.zeros((num_entries, 3), dtype=np.uint16) + + # Read magnitude, RA, and Dec from star catalog: + if star_catalog == 'bsc5': + bsc5_data_type = [('ID', np.float32), ('RA', np.float64), + ('Dec', np.float64), ('type', np.int16), + ('mag', np.int16), ('RA_pm', np.float32), ('Dec_PM', np.float32)] + with open(catalog_file_full_pathname, 'rb') as star_catalog_file: + star_catalog_file.seek(header_length) # skip header + reader = np.fromfile(star_catalog_file, dtype=bsc5_data_type, count=num_entries) + for (i, entry) in enumerate(reader): + mag = entry[4]/100 + # RA/Dec in radians at epoch proper motion start. + alpha = float(entry[1]) + delta = float(entry[2]) + cos_delta = np.cos(delta) + + # Pick up proper motion terms. See notes for hip_main and tyc_main below. + # Radians per year. + mu_alpha_cos_delta = float(entry[5]) + mu_delta = float(entry[6]) + + # See notes below. + if cos_delta > 0.05: + mu_alpha = mu_alpha_cos_delta / cos_delta + else: + mu_alpha = 0 + mu_delta = 0 + + ra = alpha + mu_alpha * (epoch_proper_motion - pm_origin) + dec = delta + mu_delta * (epoch_proper_motion - pm_origin) + star_table[i,:] = ([ra, dec, 0, 0, 0, mag]) + star_catID[i] = np.uint16(entry[0]) + elif star_catalog in ('hip_main', 'tyc_main'): + # The Hipparcos and Tycho catalogs uses International Celestial + # Reference System (ICRS) which is essentially J2000. See + # https://cdsarc.u-strasbg.fr/ftp/cats/I/239/version_cd/docs/vol1/sect1_02.pdf + # section 1.2.1 for details. + with open(catalog_file_full_pathname, 'r') as star_catalog_file: + reader = csv.reader(star_catalog_file, delimiter='|') + incomplete_entries = 0 + for (i, entry) in enumerate(reader): + # Skip this entry if mag, ra, or dec are empty. + if entry[5].isspace() or entry[8].isspace() or entry[9].isspace(): + incomplete_entries += 1 + continue + # If propagating, skip if proper motions are empty. + if epoch_proper_motion != pm_origin \ + and (entry[12].isspace() or entry[13].isspace()): + incomplete_entries += 1 + continue + mag = float(entry[5]) + # RA/Dec in degrees at 1991.25 proper motion start. + alpha = float(entry[8]) + delta = float(entry[9]) + cos_delta = np.cos(np.deg2rad(delta)) + + mu_alpha = 0 + mu_delta = 0 + if epoch_proper_motion != pm_origin: + # Pick up proper motion terms. Note that the pmRA field is + # "proper motion in right ascension"; see + # https://en.wikipedia.org/wiki/Proper_motion; see also section + # 1.2.5 in the cdsarc.u-strasbg document cited above. + + # The 1000/60/60 term converts milliarcseconds per year to + # degrees per year. + mu_alpha_cos_delta = float(entry[12])/1000/60/60 + mu_delta = float(entry[13])/1000/60/60 + + # Divide the pmRA field by cos_delta to recover the RA proper + # motion rate. Note however that near the poles (delta near plus + # or minus 90 degrees) the cos_delta term goes to zero so dividing + # by cos_delta is problematic there. + # Section 1.2.9 of the cdsarc.u-strasbg document cited above + # outlines a change of coordinate system that can overcome + # this problem; we simply punt on proper motion near the poles. + if cos_delta > 0.05: + mu_alpha = mu_alpha_cos_delta / cos_delta + else: + # abs(dec) > ~87 degrees. Ignore proper motion. + mu_alpha = 0 + mu_delta = 0 + + ra = np.deg2rad(alpha + mu_alpha * (epoch_proper_motion - pm_origin)) + dec = np.deg2rad(delta + mu_delta * (epoch_proper_motion - pm_origin)) + star_table[i,:] = ([ra, dec, 0, 0, 0, mag]) + # Find ID, depends on the database + if star_catalog == 'hip_main': + star_catID[i] = np.uint32(entry[1]) + else: # is tyc_main + star_catID[i, :] = [np.uint16(x) for x in entry[1].split()] + + if incomplete_entries: + logger.info('Skipped %i incomplete entries.' % incomplete_entries) + + # Remove entries in which RA and Dec are both zero + # (i.e. keep entries in which either RA or Dec is non-zero) + kept = np.logical_or(star_table[:, 0] != 0, star_table[:, 1] != 0) + star_table = star_table[kept, :] + brightness_ii = np.argsort(star_table[:, 5]) + star_table = star_table[brightness_ii, :] # Sort by brightness + num_entries = star_table.shape[0] + # Trim and order catalogue ID array to match + if star_catalog in ('bsc5', 'hip_main'): + star_catID = star_catID[kept][brightness_ii] + else: + star_catID = star_catID[kept, :][brightness_ii, :] + + logger.info('Loaded %d stars' % num_entries) + return (star_table, star_catID, epoch_equinox) + + def generate_database(self, max_fov, min_fov=None, save_as=None, + star_catalog='hip_main', + lattice_field_oversampling=100, patterns_per_lattice_field=50, + verification_stars_per_fov=150, star_max_magnitude=None, + pattern_max_error=.001, + multiscale_step=1.5, epoch_proper_motion='now', + pattern_stars_per_fov=None, linear_probe=False): + """Create a database and optionally save it to file. + + Takes a few minutes for a small (large FOV) database, can take many hours for a large + (small FOV) database. The primary knowledge necessary is the FOV you want the database + to work for and the highest magnitude of stars you want to include. + + For a single application, set max_fov equal to your known FOV. Alternatively, set + max_fov and min_fov to the range of FOVs you want the database to be built for. For + large difference in max_fov and min_fov, a multiscale database will be built where + patterns of several different sizes on the sky will be included. + + Note: + If you wish to build you own database you must download a star catalogue. tetra3 + supports three options, where the 'hip_main' is the default and recommended + database to use: + * The 285KB Yale Bright Star Catalog 'BSC5' containing 9,110 stars. This is complete to + to about magnitude seven and is sufficient for >10 deg field-of-view setups. + * The 51MB Hipparcos Catalogue 'hip_main' containing 118,218 stars. This contains about + three stars per square degree and is sufficient down to about >3 deg field-of-view. + * The 355MB Tycho Catalogue 'tyc_main' (also from the Hipparcos satellite mission) + containing 1,058,332 stars. This is complete to magnitude 10 and is sufficient + for all tetra3 databases. + The 'BSC5' data is avaiable from (use + byte format file) and 'hip_main' and 'tyc_main' are available from + (save the appropriate .dat file). The + downloaded catalogue must be placed in the tetra3/tetra3 directory. + + Example, the default database was generated with: + :: + + # Create instance + t3 = tetra3.Tetra3() + # Generate and save database + t3.generate_database(max_fov=30, min_fov=10, save_as='default_database') + + If you know your FOV, set max_fov to this value and leave min_fov as None. The example above + takes less than 7 minutes to build on RPi4. + + Note on celestial coordinates: The RA/Dec values incorporated into the database + are expressed in the same celestial coordinate system as the input catalog. For + hip_main and tyc_main this is J2000; for bsc5 this is also J2000 (but could be + B1950 for older Bright Star Catalogs). The solve_from_image() function returns its + solution's RA/Dec values along with the equinox epoch of the database's catalog. + + Notes on proper motion: star catalogs include stellar proper motion data. This + means they give each star's position as of a specified year (1991.25 for hip_main + and tyc_main; 2000(?) for bsc5). In addition, for each star, the annual rate of + motion in RA/Dec is also given. This allows generate_database() to output a + database with stellar positions propagated to the year in which the database was + generated (by default; see below). Some stars don't have proper motions in the + catalogue and will therefore be excluded from the database, however, you can set + epoch_proper_motion=None to disable this propagation and all stars will be + included. The field 'epoch_proper_motion' of the database properties identifies + the epoch for which the star positions are valid. + + Theoretically, when passing an image to solve_from_image(), the database's + epoch_proper_motion should be the same as the time at which the image was taken. + In practice, this is generally unimportant because most stars' proper motion is + very small. One exception: for very small fields of view (high magnification), + even small proper motions can be significant. Another exception: when solving + historical images. In both cases, you should arrange to use a database built with + a epoch_proper_motion similar to the image's vintage. + + About patterns, pattern keys, and collisions: + + Tetra3 refers to a grouping of four stars as a "pattern", and assigns each pattern + a pattern key as follows: + + 1. Calculate the six edge distances between each pair of stars in the pattern. + 2. Normalise by the longest edge to create a set of five numbers each between zero and + one. + 3. Order the five edge ratios. + 4. Quantize each edge ratio into a designated number of bins. + 5. Concatenate the five ordered and quantized edge ratios to form the key for the + pattern. + + When solving an image, tetra3 forms patterns from 4-groups of stars in the image, + computes each pattern's key in the same manner, and use these pattern keys to look + up the corresponding database pattern (or patterns, see next). The location of + stars in the database pattern and other nearby catalog stars are used to validate + the match in the image. + + Note that it is possible for multiple distinct patterns to share the same key; + this happens more frequently as the number of quantization bins in step 4 is + reduced. When multiple patterns share the same key we call this a "pattern key + collision". When solving an image, pattern key collisions increase the number of + database patterns to be validated as a match against the image's star patterns. + + In theory, a python dict could be used to map from pattern key value to the list + of patterns with that key value. Howver, catalog databases can easily contain + millions of patterns, so in practice such a pattern dict would occupy an + uncomfortably large amount of memory. + + Tetra3 instead uses an efficient array representation of its patterns, with each + pattern key value being hashed (*) to form an index into the pattern array. + Mapping the large space of possible pattern key values to the modest range of + pattern array indices induces further collisions. Because the pattern array is + allocated to larger than the number of patterns, the additional hash table + collisions induced are modest. + + * We have two hashing concepts in play. The first is "geometric hashing" from the + field of object recognition and pattern matching + (https://en.wikipedia.org/wiki/Geometric_hashing), where a 4-star pattern is + distilled to our pattern key, a 5-tuple of quantized edge ratios. The second is a + "hash table" (https://en.wikipedia.org/wiki/Hash_table) where the pattern key is + hashed to index into a compact table of all of the star patterns. + + Args: + max_fov (float): Maximum angle (in degrees) between stars in the same pattern. + min_fov (float, optional): Minimum FOV considered when the catalogue density is + trimmed to size. If None (the default), min_fov will be set to max_fov, i.e. + a catalogue for a single application is generated (this is most efficient size + and speed wise). + save_as (str or pathlib.Path, optional): Save catalogue here when finished. Calls + :meth:`save_database`. + star_catalog (string, optional): Abbreviated name of star catalog, one of 'bsc5', + 'hip_main', or 'tyc_main'. Default 'hip_main'. + lattice_field_oversampling (int, optional): When uniformly distributing pattern + generation fields over the celestial sphere, this determines the overlap factor. + Default is 100. + patterns_per_lattice_field (int, optional): The number of patterns generated for each + lattice field. Typical values are 20 to 100; default is 50. + verification_stars_per_fov (int, optional): Target number of stars used for generating + patterns in each FOV region. Also used to limit the number of stars considered for + matching in solve images. Typical values are large; default is 150. + star_max_magnitude (float, optional): Dimmest apparent magnitude of stars retained + from star catalog. None (default) causes the limiting magnitude to be computed + based on `min_fov` and `verification_stars_per_fov`. + pattern_max_error (float, optional): This value determines the number of bins into which + a pattern key's edge ratios are each quantized: + pattern_bins = 0.25 / pattern_max_error + Default .001, corresponding to pattern_bins=250. For a database with limiting + magnitude 7, this yields a reasonable pattern key collision rate. + multiscale_step (float, optional): Determines the largest ratio between subsequent FOVs + that is allowed when generating a multiscale database. Defaults to 1.5. If the ratio + max_fov/min_fov is less than sqrt(multiscale_step) a single scale database is built. + epoch_proper_motion (string or float, optional): Determines the end year to which + stellar proper motions are propagated. If 'now' (default), the current year is used. + If 'none' or None, star motions are not propagated and this allows catalogue entries + without proper motions to be used in the database. + pattern_stars_per_fov (int, optional): Deprecated. If given, is used instead of + `lattice_field_oversampling`, which has similar values. + linear_probe (bool, optional): If False (default), uses quadratic probing in the + hash table. This is appropriate for deployments where you expect the pattern + database to fit entirely in RAM. Use linear_probe=True when you expect the + pattern database to be too large to fit in RAM. + + """ + self._logger.debug('Got generate pattern catalogue with input: ' + + str((max_fov, min_fov, save_as, star_catalog, + lattice_field_oversampling, + patterns_per_lattice_field, verification_stars_per_fov, + star_max_magnitude, pattern_max_error, + multiscale_step, epoch_proper_motion, linear_probe))) + if pattern_stars_per_fov is not None and pattern_stars_per_fov != lattice_field_oversampling: + self._logger.warning( + 'pattern_stars_per_fov value %s is overriding lattice_field_oversampling value %s' % + (pattern_stars_per_fov, lattice_field_oversampling)) + lattice_field_oversampling = pattern_stars_per_fov + + # If True, measures and logs collisions (pattern key, hash table). + EVALUATE_COLLISIONS = False + + star_catalog, catalog_file_full_pathname = self._build_catalog_path(star_catalog) + + max_fov = np.deg2rad(float(max_fov)) + if min_fov is None: + min_fov = max_fov + else: + min_fov = np.deg2rad(float(min_fov)) + + # Making lattice_field_oversampling larger yields more patterns, with diminishing + # returns. + # value fraction of patterns found compared to lattice_field_oversampling=100000 + # 100 0.61 + # 1000 0.86 + # 10000 0.96 + lattice_field_oversampling = int(lattice_field_oversampling) + + patterns_per_lattice_field = int(patterns_per_lattice_field) + verification_stars_per_fov = int(verification_stars_per_fov) + linear_probe = bool(linear_probe) + if star_max_magnitude is not None: + star_max_magnitude = float(star_max_magnitude) + PATTERN_SIZE = 4 + pattern_bins = round(1/4/pattern_max_error) + if epoch_proper_motion is None or str(epoch_proper_motion).lower() == 'none': + epoch_proper_motion = None + self._logger.debug('Proper motions will not be considered') + elif isinstance(epoch_proper_motion, Number): + self._logger.debug('Use proper motion epoch as given') + elif str(epoch_proper_motion).lower() == 'now': + epoch_proper_motion = datetime.utcnow().year + self._logger.debug('Proper motion epoch set to now: ' + str(epoch_proper_motion)) + else: + raise ValueError('epoch_proper_motion value %s is forbidden' % epoch_proper_motion) + + star_table, star_catID, epoch_equinox = Tetra3._load_catalog( + star_catalog, + catalog_file_full_pathname, + epoch_proper_motion, + self._logger, + ) + + if star_max_magnitude is None: + # Compute the catalog magnitude cutoff based on the required star density. + + # First, characterize the catalog star brightness distribution. + mag_histo_values, mag_histo_edges = np.histogram(star_table[:, 5], bins=100) + index_of_peak = np.argmax(mag_histo_values) + catalog_mag_limit = mag_histo_edges[index_of_peak] + catalog_mag_max = mag_histo_edges[-1] + self._logger.debug('Catalog star counts peak: mag=%.1f' % catalog_mag_limit) + + # How many FOVs are in the entire sky? + num_fovs = num_fields_for_sky(min_fov) + + # The total number of stars needed. + total_stars_needed = num_fovs * verification_stars_per_fov + + # Empirically determined fudge factor. With this, the star_max_magnitude is + # about 0.5 magnitude fainter than the dimmest pattern star. + total_stars_needed *= 0.7 + + cumulative = np.cumsum(mag_histo_values) + mag_index = np.where(cumulative > total_stars_needed)[0] + if mag_index.size == 0: + star_max_magnitude = catalog_mag_max + else: + star_max_magnitude = mag_histo_edges[mag_index[0]] + if star_max_magnitude > catalog_mag_limit: + self._logger.warning('Catalog magnitude limit %.1f is too low to provide %d stars' % + (catalog_mag_limit, total_stars_needed)) + + kept = star_table[:, 5] <= star_max_magnitude + star_table = star_table[kept, :] + if star_catalog in ('bsc5', 'hip_main'): + star_catID = star_catID[kept] + else: + star_catID = star_catID[kept, :] + + num_entries = star_table.shape[0] + self._logger.info('Kept %d stars brighter than magnitude %.1f.' % + (num_entries, star_max_magnitude)) + + # Calculate star direction vectors. + for i in range(0, num_entries): + vector = np.array([np.cos(star_table[i, 0])*np.cos(star_table[i, 1]), + np.sin(star_table[i, 0])*np.cos(star_table[i, 1]), + np.sin(star_table[i, 1])]) + star_table[i, 2:5] = vector + + # Insert all stars in a KD-tree for fast neighbour lookup + all_star_vectors = star_table[:, 2:5] + vector_kd_tree = KDTree(all_star_vectors) + + # Calculate set of FOV scales to create patterns at + fov_ratio = max_fov/min_fov + def logk(x, k): + return np.log(x) / np.log(k) + fov_divisions = np.ceil(logk(fov_ratio, multiscale_step)).astype(int) + 1 + if fov_ratio < np.sqrt(multiscale_step): + pattern_fovs = [max_fov] + else: + pattern_fovs = np.exp2(np.linspace(np.log2(min_fov), np.log2(max_fov), fov_divisions)) + self._logger.info('Generating patterns at FOV scales: ' + str(np.rad2deg(pattern_fovs))) + + # Theory of operation: + # + # We want our 4-star patterns to satisfy three criteria: be well distributed over the + # sky; favor bright stars; and have size commensurate with the FOV. + # + # Well distributed: we establish this by creating a set of FOV-sized "lattice fields" + # uniformly distributed over the celestial sphere. Within each lattice field we generate a + # fixed number (`patterns_per_lattice_field`, typically 50) of patterns, thus ensuring that + # all parts of the sky have the same density of database patterns. Because a solve-time + # field of view might not line up with a lattice field, a `lattice_field_oversampling` + # parameter (typically 100) is used to increase the number of lattice fields, overlapping + # them. + # + # Favor bright stars: within each lattice field, nearly all (see below) sky catalog stars in + # the lattice field are considered. Working with the brightest stars first, we form the + # desired number (`patterns_per_lattice_field`) of 4-star subsets within the field. + # + # Sized for FOV: in each lattice field, we form patterns using stars within FOV/2 radius of + # the field's center, thus ensuring that the resulting patterns will not be too large for + # the FOV. Because we work with the brightest stars first, most of the time patterns won't + # be too small for the FOV because brighter stars occur less frequently and are thus further + # apart on average. + # + # In the previous paragraph we appeal to the power law spatial distrbution of stars by + # brightness, so usually if we choose the brightest stars in a lattice field, the resulting + # patterns won't be tiny (because bright stars are spaced apart on average). However, + # clusters of bright stars do occur and if we aren't careful we could end up using up most + # of the `patterns_per_lattice_field` budget generating tiny patterns among the brightest + # cluster stars. + # + # Consider M45 (Pleiades). Within its roughly one degree diameter core, it has more than ten + # bright stars. 10 choose 4 is 210, so if patterns_per_lattice_field=50, we will generate + # all the patterns from the brightest Pleiades' stars. If the FOV is 10 degrees, these patterns + # will be of limited utility for plate solving because they are all very small relative to + # the FOV. + # + # We address this problem by applying a `pattern_stars_separation` constraint to the sky + # catalog stars before choosing a lattice field's pattern stars. In our 10 degree FOV + # example, a pattern_stars_separation of 1/2 degree creates an "exclusion zone" around each + # of the Pleiades brightest stars, leaving us with only the 5 or 6 most separated bright + # Pleiades stars. 6 choose 4 is just 15, so if `patterns_per_lattice_field` is larger than + # this (50 is typical), we'll generate plenty of patterns that include stars other than only + # the Pleiades members. + # + # A similar "cluster buster" step is performed at solve time, eliminating centroids that + # are too closely spaced. + + # Set of deduped patterns found, to be populated across all FOVs. + pattern_list = set() + for pattern_fov in reversed(pattern_fovs): + keep_for_patterns_at_fov = np.full(num_entries, False) + if fov_divisions == 1: + # Single scale database, trim to min_fov, make patterns up to max_fov + pattern_stars_separation = separation_for_density( + min_fov, verification_stars_per_fov) + else: + # Multiscale database, trim and make patterns iteratively at smaller FOVs + pattern_stars_separation = separation_for_density( + pattern_fov, verification_stars_per_fov) + self._logger.info('At FOV %s separate pattern stars by %.2f deg.' % + (round(np.rad2deg(pattern_fov), 5), + np.rad2deg(pattern_stars_separation))) + pattern_stars_dist = _distance_from_angle(pattern_stars_separation) + + # Loop through all stars in database, gather pattern stars for this FOV. + for star_ind in range(num_entries): + vector = all_star_vectors[star_ind, :] + # Check if any kept pattern stars are within the separation. + within_pattern_separation = vector_kd_tree.query_ball_point( + vector, pattern_stars_dist) + occupied_for_pattern = np.any(keep_for_patterns_at_fov[within_pattern_separation]) + # If there isn't a pattern star too close, add this to the pattern table. + if not occupied_for_pattern: + keep_for_patterns_at_fov[star_ind] = True + self._logger.info('Pattern stars at this FOV: %s.' % np.sum(keep_for_patterns_at_fov)) + + # Clip out tables of the kept stars. + pattern_star_table = star_table[keep_for_patterns_at_fov, :] + + # Insert pattern stars into KD tree for lattice field lookup. + pattern_kd_tree = KDTree(pattern_star_table[:, 2:5]) + + # Index conversion from pattern star_table to main star_table + pattern_index = np.nonzero(keep_for_patterns_at_fov)[0].tolist() + + # To ensure good coverage of patterns for the largest FOV of interest, you can just + # specify a somewhat larger `max_fov`. + fov_angle = pattern_fov / 2 + fov_dist = _distance_from_angle(fov_angle) + + # Enumerate all lattice fields over the celestial sphere. + total_field_pattern_stars = 0 + total_added_patterns = 0 + total_pattern_avg_mag = 0 + max_pattern_mag = -1 + min_stars_per_lattice_field = len(pattern_star_table) # Exceeds any possible value. + num_lattice_fields = 0 + n = num_fields_for_sky(pattern_fov) * lattice_field_oversampling + for lattice_field_center_vector in fibonacci_sphere_lattice(n): + # Find all pattern stars within lattice field. + field_pattern_stars = pattern_kd_tree.query_ball_point(lattice_field_center_vector, fov_dist) + min_stars_per_lattice_field = min(len(field_pattern_stars), min_stars_per_lattice_field) + total_field_pattern_stars += len(field_pattern_stars) + num_lattice_fields += 1 + # Change to main star_table indices. + field_pattern_stars = [pattern_index[n] for n in field_pattern_stars] + field_pattern_stars.sort() # Brightness order. + + # Check all possible patterns in overall brightness order until we've accepted + # 'patterns_per_lattice_field' patterns. + patterns_this_lattice_field = 0 + for pattern in breadth_first_combinations(field_pattern_stars, PATTERN_SIZE): + len_before = len(pattern_list) + pattern_list.add(tuple(pattern)) # Add to set, deduping. + if len(pattern_list) > len_before: + total_added_patterns += 1 + total_mag = sum(star_table[p, 5] for p in pattern) + total_pattern_avg_mag += total_mag / PATTERN_SIZE + max_pattern_mag = max(star_table[pattern[-1], 5], max_pattern_mag) + if len(pattern_list) % 100000 == 0: + self._logger.info('Generated %s patterns so far.' % len(pattern_list)) + + patterns_this_lattice_field += 1 + if patterns_this_lattice_field >= patterns_per_lattice_field: + break + + self._logger.info( + 'avg/min pattern stars per lattice field %.2f/%d; avg/max pattern mag %.2f/%.2f' % + (total_field_pattern_stars / num_lattice_fields, + min_stars_per_lattice_field, + total_pattern_avg_mag / total_added_patterns, + max_pattern_mag)) + + pattern_list = list(pattern_list) + self._logger.info('Found %s patterns in total.' % len(pattern_list)) + + # Don't need this anymore. + del pattern_kd_tree + + # Create all pattern keys by calculating, sorting, and binning edge ratios; then compute + # a table index hash from the pattern key, and store the table index -> pattern mapping. + self._logger.info('Start building catalogue.') + if linear_probe: + catalog_length = int(_next_prime(3 * len(pattern_list))) + else: + catalog_length = int(_next_prime(2 * len(pattern_list))) + # Determine type to make sure the biggest index will fit, create pattern catalogue + max_index = np.max(np.array(pattern_list)) + if max_index <= np.iinfo('uint8').max: + pattern_catalog = np.zeros((catalog_length, PATTERN_SIZE), dtype=np.uint8) + elif max_index <= np.iinfo('uint16').max: + pattern_catalog = np.zeros((catalog_length, PATTERN_SIZE), dtype=np.uint16) + else: + pattern_catalog = np.zeros((catalog_length, PATTERN_SIZE), dtype=np.uint32) + self._logger.info('Catalog size %s and type %s.' % + (pattern_catalog.shape, pattern_catalog.dtype)) + + pattern_largest_edge = np.zeros(catalog_length, dtype=np.float16) + pattern_key_hashes = np.zeros(catalog_length, dtype=np.uint16) + + # Gather collision information. + pattern_keys_seen = set() + pattern_key_collisions = 0 + + # Go through each pattern and insert to the catalogue + for (pat_index, pattern) in enumerate(pattern_list): + if pat_index % 100000 == 0 and pat_index > 0: + self._logger.info('Inserting pattern number: ' + str(pat_index)) + + # retrieve the vectors of the stars in the pattern + vectors = [star_table[p, 2:5].tolist() for p in pattern] + + edge_angles = [2.0 * math.asin(0.5 * math.dist(vectors[i], vectors[j])) + for i, j in itertools.combinations(range(4), 2)] + edge_angles_sorted = sorted(edge_angles) + largest_angle = edge_angles_sorted[-1] + edge_ratios = [angle / largest_angle for angle in edge_angles_sorted[:-1]] + + # Convert edge ratio float to pattern key by binning. + pattern_key = [int(ratio * pattern_bins) for ratio in edge_ratios] + pattern_key_hash = _compute_pattern_key_hash(pattern_key, pattern_bins) + hash_index = _pattern_key_hash_to_index( + pattern_key_hash, catalog_length, linear_probe) + + if EVALUATE_COLLISIONS: + prev_len = len(pattern_keys_seen) + pattern_keys_seen.add(tuple(pattern_key)) + if prev_len == len(pattern_keys_seen): + pattern_key_collisions += 1 + + # Presort patterns. + # Find the centroid, or average position, of the star pattern. + pattern_centroid = list(map(lambda a : sum(a) / len(a), zip(*vectors))) + + # Calculate each star's radius, or Euclidean distance from the centroid. + + # Elements: (distance, index in pattern). + centroid_distances = [ + (sum((x1 - x2) * (x1 - x2) for (x1, x2) in zip(v, pattern_centroid)), index) + for index, v in enumerate(vectors)] + centroid_distances.sort() + # Use the radii to uniquely order the pattern, used for future matching. + pattern = [pattern[i] for (_, i) in centroid_distances] + + index = _insert_at_index(pattern, hash_index, pattern_catalog, linear_probe) + pattern_key_hashes[index] = np.uint16(int(pattern_key_hash) & 0xffff) + # Store as milliradian to better use float16 range. + pattern_largest_edge[index] = largest_angle*1000 + + total_probes = 0 + max_probes = 0 + if EVALUATE_COLLISIONS: + # Evaluate average hash table probe count. + for pattern_key in pattern_keys_seen: + pattern_key_hash = _compute_pattern_key_hash( + pattern_key, pattern_bins) + hash_index = _pattern_key_hash_to_index( + pattern_key_hash, catalog_length, linear_probe) + hash_match_inds = _get_table_indices_from_hash( + hash_index, pattern_catalog, linear_probe) + probes = len(hash_match_inds) + total_probes += probes + if probes > max_probes: + max_probes = probes + + self._logger.info('Finished generating database.') + self._logger.info('Size of uncompressed star table: %i Bytes.' %star_table.nbytes) + self._logger.info('Size of uncompressed pattern catalog: %i Bytes.' %pattern_catalog.nbytes) + if EVALUATE_COLLISIONS: + self._logger.info('Pattern key collisions: %s; average/max hash table probe len: %.2f/%d' + % (pattern_key_collisions, + total_probes / len(pattern_keys_seen), + max_probes)) + self._star_table = star_table + self._star_kd_tree = vector_kd_tree + self._star_catalog_IDs = star_catID + self._pattern_catalog = pattern_catalog + self._pattern_largest_edge = pattern_largest_edge + self._pattern_key_hashes = pattern_key_hashes + self._db_props['pattern_mode'] = 'edge_ratio' + self._db_props['hash_table_type'] = 'linear_probe' if linear_probe else 'quadratic_probe' + self._db_props['pattern_size'] = PATTERN_SIZE + self._db_props['pattern_bins'] = pattern_bins + self._db_props['pattern_max_error'] = pattern_max_error + self._db_props['max_fov'] = np.rad2deg(max_fov) + self._db_props['min_fov'] = np.rad2deg(min_fov) + self._db_props['star_catalog'] = star_catalog + self._db_props['epoch_equinox'] = epoch_equinox + self._db_props['epoch_proper_motion'] = epoch_proper_motion + self._db_props['lattice_field_oversampling'] = lattice_field_oversampling + self._db_props['anchor_stars_per_fov'] = lattice_field_oversampling # legacy + self._db_props['pattern_stars_per_fov'] = lattice_field_oversampling # legacy + self._db_props['patterns_per_lattice_field'] = patterns_per_lattice_field + self._db_props['patterns_per_anchor_star'] = patterns_per_lattice_field # legacy + self._db_props['verification_stars_per_fov'] = verification_stars_per_fov + self._db_props['star_max_magnitude'] = star_max_magnitude + self._db_props['simplify_pattern'] = True # legacy + self._db_props['range_ra'] = None + self._db_props['range_dec'] = None + self._db_props['presort_patterns'] = True # legacy + self._db_props['num_patterns'] = len(pattern_list) + self._logger.debug(self._db_props) + + if save_as is not None: + self._logger.debug('Saving generated database as: ' + str(save_as)) + self.save_database(save_as) + else: + self._logger.info('Skipping database file generation.') + + def solve_from_image(self, image, fov_estimate=None, fov_max_error=None, + match_radius=.01, match_threshold=1e-5, + solve_timeout=5000, target_pixel=None, target_sky_coord=None, distortion=0, + return_matches=False, return_visual=False, match_max_error=.002, + pattern_checking_stars=None, **kwargs): + """Solve for the sky location of an image. + + Star locations (centroids) are found using :meth:`tetra3.get_centroids_from_image` and + keyword arguments are passed along to this method. Every 4-star combination of the + found stars found is checked against the database before giving up (or the `solve_timeout` + is reached). + + Example: + :: + + # Create dictionary with desired extraction settings + extract_dict = {'min_sum': 250, 'max_axis_ratio': 1.5} + # Solve for image + result = t3.solve_from_image(image, **extract_dict) + + Args: + image (PIL.Image): The image to solve for, must be convertible to numpy array. + fov_estimate (float, optional): Estimated horizontal field of view of the image in + degrees. + fov_max_error (float, optional): Maximum difference in field of view from the estimate + allowed for a match in degrees. + match_radius (float, optional): Maximum distance to a star to be considered a match + as a fraction of the image field of view. + match_threshold (float, optional): Maximum allowed false-positive probability to accept + a tested pattern a valid match. Default 1e-5. + solve_timeout (float, optional): Timeout in milliseconds after which the solver will + give up on matching patterns. Defaults to 5000 (5 seconds). + target_pixel (numpy.ndarray, optional): Pixel coordinates to return RA/Dec for in + addition to the default (the centre of the image). Size (N,2) where each row is the + (y, x) coordinate measured from top left corner of the image. Defaults to None. + target_sky_coord (numpy.ndarray, optional): Sky coordinates to return image (y, x) for. + Size (N,2) where each row is the (RA, Dec) in degrees. Defaults to None. + distortion (float, optional): Set the estimated distortion of the image. + Negative distortion is barrel, positive is pincushion. Given as amount of distortion + at width/2 from centre. Can set to None to disable distortion calculation entirely. + Default 0. + return_matches (bool, optional): If set to True, the catalogue entries of the mached + stars and their pixel coordinates in the image is returned. + return_visual (bool, optional): If set to True, an image is returned that visualises + the solution. + match_max_error (float, optional): Maximum difference allowed in pattern for a match. + If None, uses the 'pattern_max_error' value from the database. + pattern_checking_stars: No longer meaningful, ignored. + **kwargs (optional): Other keyword arguments passed to + :meth:`tetra3.get_centroids_from_image`. + + Returns: + dict: A dictionary with the following keys is returned: + - 'RA': Right ascension of centre of image in degrees. + - 'Dec': Declination of centre of image in degrees. + - 'Roll': Rotation in degrees of celestial north relative to image's "up" + direction (towards y=0). Zero when north and up coincide; a positive + roll angle means north is counter-clockwise from image "up". + - 'FOV': Calculated horizontal field of view of the provided image. + - 'distortion': Calculated distortion of the provided image. Omitted if + the caller's distortion estimate is None. + - 'RMSE': RMS residual of matched stars in arcseconds. + - 'P90E': 90 percentile matched star residual in arcseconds. + - 'MAXE': Maximum matched star residual in arcseconds. + - 'Matches': Number of stars in the image matched to the database. + - 'Prob': Probability that the solution is a false-positive. + - 'epoch_equinox': The celestial RA/Dec equinox reference epoch. + - 'epoch_proper_motion': The epoch the database proper motions were propageted to. + - 'T_solve': Time spent searching for a match in milliseconds. + - 'T_extract': Time spent exctracting star centroids in milliseconds. + - 'RA_target': Right ascension in degrees of the pixel positions passed in + target_pixel. Not included if target_pixel=None (the default). + - 'Dec_target': Declination in degrees of the pixel positions in target_pixel. + Not included if target_pixel=None (the default). + - 'x_target': image x coordinates for the sky positions passed in target_sky_coord. + If a sky position is outside of the field of view, the corresponding x_target + entry will be None. Not included if target_sky_coord=None (the default). + - 'y_target': image y coordinates for the sky positions passed in target_sky_coord. + If a sky position is outside of the field of view, the corresponding y_target + entry will be None. Not included if target_sky_coord=None (the default). + - 'matched_stars': An Mx3 list with the (RA, Dec, magnitude) of the M matched stars + that were used in the solution. RA/Dec in degrees. Not included if + return_matches=False (the default). + - 'matched_centroids': An Mx2 list with the (y, x) pixel coordinates in the image + corresponding to each matched star. Not included if return_matches=False. + - 'matched_catID': The catalogue ID corresponding to each matched star. See + Tetra3.star_catalog_IDs for information on the format. Not included if + return_matches=False. + - 'pattern_centroids': similar to matched_centroids, except just for the pattern + stars. Not included if return_matches=False. + - 'visual': A PIL image with spots for the given centroids in white, the coarse + FOV and distortion estimates in orange, the final FOV and distortion + estimates in green. Also has circles for the catalogue stars in green or + red for successful/unsuccessful match. Not included if return_visual=False. + - 'status': One of: + MATCH_FOUND: solution was obtained + NO_MATCH: no match was found after exhausting all possibilities + TIMEOUT: the 'solve_timeout' was reached before a match could be found + CANCELLED: the solve operation was cancelled before a match could be found + TOO_FEW: the 'image' has too few detected stars to attempt a pattern match + + If unsuccessful in finding a match, None is returned for all keys of the + dictionary except 'T_solve' and 'status', and the optional return keys are missing. + + """ + assert self.has_database, 'No database loaded' + self._logger.debug('Got solve from image with input: ' + str( + (image, fov_estimate, fov_max_error, match_radius, + match_threshold, solve_timeout, target_pixel, target_sky_coord, distortion, + return_matches, return_visual, match_max_error, kwargs))) + (width, height) = image.size[:2] + self._logger.debug('Image (height, width): ' + str((height, width))) + + # Run star extraction, passing kwargs along + t0_extract = precision_timestamp() + centr_data = get_centroids_from_image(image, **kwargs) + t_extract = (precision_timestamp() - t0_extract)*1000 + # If we get a tuple, need to use only first element and then reassemble at return + if isinstance(centr_data, tuple): + centroids = centr_data[0] + else: + centroids = centr_data + self._logger.debug('Found this many centroids, in time: ' + str((len(centroids), t_extract))) + # Run centroid solver, passing arguments along (could clean up with kwargs handler) + solution = self.solve_from_centroids( + centroids, (height, width), fov_estimate=fov_estimate, fov_max_error=fov_max_error, + match_radius=match_radius, match_threshold=match_threshold, + solve_timeout=solve_timeout, target_pixel=target_pixel, + target_sky_coord=target_sky_coord, distortion=distortion, + return_matches=return_matches, return_visual=return_visual, + match_max_error=match_max_error) + # Add extraction time to results and return + solution['T_extract'] = t_extract + if isinstance(centr_data, tuple): + return (solution,) + centr_data[1:] + else: + return solution + + def solve_from_centroids(self, star_centroids, size, fov_estimate=None, fov_max_error=None, + match_radius=.01, match_threshold=1e-5, + solve_timeout=5000, target_pixel=None, target_sky_coord=None, + distortion=0, return_matches=False, return_catalog=False, + return_visual=False, return_rotation_matrix=False, + match_max_error=.002, pattern_checking_stars=None): + """Solve for the sky location using a list of centroids. + + Use :meth:`tetra3.get_centroids_from_image` or your own centroiding algorithm to + find an array of all the stars in your image and pass this result along with the + resolution of the image to this method. + + Every 4-star combination of the `star_centroids` found is checked against the + database before giving up (or the `solve_timeout` is reached). Since patterns + contain four stars, there will be N choose 4 (potentially a very large number!) + patterns tested against the database, so it is important to specify a meaningful + `solve_timeout`. + + Passing an estimated FOV and error bounds yields solutions much faster that letting tetra3 + figure it out. + + Example: + :: + + # Get centroids from image with custom parameters + centroids = get_centroids_from_image(image, simga=2, filtsize=30) + # Solve from centroids + result = t3.solve_from_centroids(centroids, size=image.size, fov_estimate=13) + + Args: + star_centroids (numpy.ndarray): (N,2) list of centroids, ordered by brightest first. + Each row is the (y, x) position of the star measured from the top left corner. + size (tuple of floats): (height, width) of the centroid coordinate system (i.e. + image resolution). + fov_estimate (float, optional): Estimated horizontal field of view of the image in + degrees. Default None. + fov_max_error (float, optional): Maximum difference in field of view from the estimate + allowed for a match in degrees. Default None. + match_radius (float, optional): Maximum distance to a star to be considered a match + as a fraction of the image field of view. Default 0.01. + match_threshold (float, optional): Maximum allowed false-positive probability to accept + a tested pattern a valid match. Default 1e-5. + solve_timeout (float, optional): Timeout in milliseconds after which the solver will + give up on matching patterns. Defaults to 5000 (5 seconds). + target_pixel (numpy.ndarray, optional): Pixel coordinates to return RA/Dec for in + addition to the default (the centre of the image). Size (N,2) where each row is the + (y, x) coordinate measured from top left corner of the image. Defaults to None. + target_sky_coord (numpy.ndarray, optional): Sky coordinates to return image (y, x) for. + Size (N,2) where each row is the (RA, Dec) in degrees. Defaults to None. + distortion (float, optional): Set the estimated distortion of the image. + Negative distortion is barrel, positive is pincushion. Given as amount of distortion + at width/2 from centre. Can set to None to disable distortion calculation entirely. + Default 0. + return_matches (bool, optional): If set to True, the catalogue entries of the matched + stars and their pixel coordinates in the image is returned. + return_catalog (bool, optional): If set to True, information about catalog stars in + the image's FOV is returned. + return_visual (bool, optional): If set to True, an image is returned that visualises + the solution. + return_rotation_matrix (bool, optional): If True, the 3x3 rotation matrix is returned. + match_max_error (float, optional): Maximum difference allowed in pattern for a match. + If None, uses the 'pattern_max_error' value from the database. + pattern_checking_stars: No longer meaningful, ignored. + + Returns: + dict: A dictionary with the following keys is returned: + - 'RA': Right ascension of centre of image in degrees. + - 'Dec': Declination of centre of image in degrees. + - 'Roll': Rotation in degrees of celestial north relative to image's "up" + direction (towards y=0). Zero when north and up coincide; a positive + roll angle means north is counter-clockwise from image "up". + - 'FOV': Calculated horizontal field of view of the provided image. + - 'distortion': Calculated distortion of the provided image. Omitted if + the caller's distortion estimate is None. + - 'RMSE': RMS residual of matched stars in arcseconds. + - 'P90E': 90 percentile matched star residual in arcseconds. + - 'MAXE': Maximum matched star residual in arcseconds. + - 'Matches': Number of stars in the image matched to the database. + - 'Prob': Probability that the solution is a false-positive. + - 'epoch_equinox': The celestial RA/Dec equinox reference epoch. + - 'epoch_proper_motion': The epoch the database proper motions were propageted to. + - 'T_solve': Time spent searching for a match in milliseconds. + - 'RA_target': Right ascension in degrees of the pixel positions passed in + target_pixel. Not included if target_pixel=None (the default). If a Kx2 array + of target_pixel was passed, this will be a length K list. + - 'Dec_target': Declination in degrees of the pixel positions in target_pixel. + Not included if target_pixel=None (the default). If a Kx2 array + of target_pixel was passed, this will be a length K list. + - 'x_target': image x coordinates for the sky positions passed in target_sky_coord. + If a sky position is outside of the field of view, the corresponding x_target + entry will be None. Not included if target_sky_coord=None (the default). + - 'y_target': image y coordinates for the sky positions passed in target_sky_coord. + If a sky position is outside of the field of view, the corresponding y_target + entry will be None. Not included if target_sky_coord=None (the default). + - 'matched_stars': An Mx3 list with the (RA, Dec, magnitude) of the M matched stars + that were used in the solution. RA/Dec in degrees. Not included if + return_matches=False (the default). + - 'matched_centroids': An Mx2 list with the (y, x) pixel coordinates in the image + corresponding to each matched star. Not included if return_matches=False. + - 'matched_catID': The catalogue ID corresponding to each matched star. See + Tetra3.star_catalog_IDs for information on the format. Not included if + return_matches=False. + - 'catalog_stars': A list of tuples (RA, Dec, magnitude, y, x). RA/Dec in degrees. + Not included if return_catalog=False. + - 'pattern_centroids': similar to matched_centroids, except just for the pattern + stars. Not included if return_matches=False. + - 'visual': A PIL image with spots for the given centroids in white, the coarse + FOV and distortion estimates in orange, the final FOV and distortion + estimates in green. Also has circles for the catalogue stars in green or + red for successful/unsuccessful match. Not included if return_visual=False. + - 'rotation_matrix' 3x3 rotation matrix. Not included if + return_rotation_matrix=False. + - 'status': One of: + MATCH_FOUND: solution was obtained + NO_MATCH: no match was found after exhausting all possibilities + TIMEOUT: the 'solve_timeout' was reached before a match could be found + CANCELLED: the solve operation was cancelled before a match could be found + TOO_FEW: the 'image' has too few detected stars to attempt a pattern match + + If unsuccessful in finding a match, None is returned for all keys of the + dictionary except 'T_solve' and 'status', and the optional return keys are missing. + + """ + assert self.has_database, 'No database loaded' + t0_solve = precision_timestamp() + self._logger.debug('Got solve from centroids with input: ' + + str((len(star_centroids), size, fov_estimate, fov_max_error, + match_radius, match_threshold, + solve_timeout, target_pixel, target_sky_coord, distortion, + return_matches, return_catalog, return_visual, match_max_error))) + if fov_estimate is None: + # If no FOV given at all, guess middle of the range for a start + fov_initial = np.deg2rad((self._db_props['max_fov'] + self._db_props['min_fov'])/2) + else: + fov_estimate = np.deg2rad(float(fov_estimate)) + fov_initial = fov_estimate + if fov_max_error is not None: + fov_max_error = np.deg2rad(float(fov_max_error)) + match_radius = float(match_radius) + match_threshold = float(match_threshold) / self.num_patterns + self._logger.debug('Set threshold to: ' + str(match_threshold) + ', have ' + + str(self.num_patterns) + ' patterns.') + if solve_timeout is not None: + # Convert to seconds to match timestamp + solve_timeout = float(solve_timeout) / 1000 + if target_pixel is not None: + target_pixel = np.array(target_pixel) + if target_pixel.ndim == 1: + # Make shape (2,) array to (1,2), to match (N,2) pattern + target_pixel = target_pixel[None, :] + if target_sky_coord is not None: + target_sky_coord = np.array(target_sky_coord) + if target_sky_coord.ndim == 1: + # Make shape (2,) array to (1,2), to match (N,2) pattern + target_sky_coord = target_sky_coord[None, :] + return_matches = bool(return_matches) + return_catalog = bool(return_catalog) + + # extract height (y) and width (x) of image + (height, width) = size[:2] + # Extract relevant database properties + verification_stars_per_fov = self._db_props['verification_stars_per_fov'] + p_size = self._db_props['pattern_size'] + p_bins = self._db_props['pattern_bins'] + if match_max_error is None or match_max_error < self._db_props['pattern_max_error']: + match_max_error = self._db_props['pattern_max_error'] + p_max_err = match_max_error + presorted = self._db_props['presort_patterns'] + linear_probe = self._db_props['hash_table_type'] == 'linear_probe' + + # Indices to extract from dot product matrix (above diagonal) + upper_tri_index = np.triu_indices(p_size, 1) + + num_centroids = len(star_centroids) + image_centroids = np.asarray(star_centroids) + if num_centroids < p_size: + return {'RA': None, 'Dec': None, 'Roll': None, 'FOV': None, 'distortion': None, + 'RMSE': None, 'P90E': None, 'MAXE': None, 'Matches': None, 'Prob': None, + 'epoch_equinox': None, 'epoch_proper_motion': None, 'T_solve': 0, + 'status': TOO_FEW} + + # Apply the same "cluster buster" thinning strategy as is used in database + # construction. + pattern_stars_separation_pixels = width * separation_for_density( + fov_initial, verification_stars_per_fov) / fov_initial + keep_for_patterns = np.full(num_centroids, False) + centroids_kd_tree = KDTree(image_centroids) + for ind in range(num_centroids): + centroid = image_centroids[ind, :] + within_separation = centroids_kd_tree.query_ball_point( + centroid, pattern_stars_separation_pixels) + occupied = np.any(keep_for_patterns[within_separation]) + # If there isn't a pattern star too close, add this to the pattern table. + if not occupied: + keep_for_patterns[ind] = True + pattern_centroids_inds = np.nonzero(keep_for_patterns)[0] + num_pattern_centroids = len(pattern_centroids_inds) + if num_pattern_centroids < num_centroids: + self._logger.debug('Trimmed %d pattern centroids to %d' % + (num_centroids, num_pattern_centroids)) + + if num_centroids > verification_stars_per_fov: + image_centroids = image_centroids[:verification_stars_per_fov, :] + self._logger.debug('Trimmed %d match centroids to %d' % + (num_centroids, len(image_centroids))) + num_centroids = len(image_centroids) + + if isinstance(distortion, (list, tuple)): + self._logger.warning('Tuple distortion %s no longer supported, ignoring' % distortion) + distortion = None + elif distortion is not None and not isinstance(distortion, Number): + self._logger.warning('Non-numeric distortion %s given, ignoring' % distortion) + distortion = None + + if distortion is None: + image_centroids_undist = image_centroids + else: + # If caller-estimated distortion, undistort centroids, then proceed as normal + image_centroids_undist = _undistort_centroids( + image_centroids, (height, width), k=distortion) + self._logger.debug('Undistorted centroids with k=%d' % distortion) + + # Compute star vectors using an estimate for the field-of-view in the x dimension + image_centroids_vectors = _compute_vectors( + image_centroids_undist, (height, width), fov_initial) + + catalog_lookup_count = 0 + catalog_eval_count = 0 + image_patterns_evaluated = 0 + search_space_explored = 0 + + # Try all `p_size` star combinations chosen from the image centroids, brightest first. + self._logger.debug('Checking up to %d image patterns from %d pattern centroids.' % + (math.comb(num_pattern_centroids, p_size), num_pattern_centroids)) + status = NO_MATCH + for image_pattern_indices in breadth_first_combinations(pattern_centroids_inds, p_size): + # Check if timeout has elapsed, then we must give up + if solve_timeout is not None: + elapsed_time = precision_timestamp() - t0_solve + if elapsed_time > solve_timeout: + self._logger.debug('Timeout reached after: %.2f sec.' % elapsed_time) + status = TIMEOUT + break + if self._cancelled: + elapsed_time = precision_timestamp() - t0_solve + self._logger.debug('Cancelled after: %.3f sec.' % elapsed_time) + status = CANCELLED + self._cancelled = False + break + + # Set largest distance to None, this is cached to avoid recalculating in future + # FOV estimation. + image_pattern_largest_distance = None + + image_pattern_vectors = image_centroids_vectors[image_pattern_indices, :] + # Calculate what the edge ratios are and broaden by p_max_err tolerance + edge_angles_sorted = np.sort(_angle_from_distance(pdist(image_pattern_vectors))) + image_pattern_largest_edge = edge_angles_sorted[-1] + image_pattern = edge_angles_sorted[:-1] / image_pattern_largest_edge + image_pattern_edge_ratio_min = image_pattern - p_max_err + image_pattern_edge_ratio_max = image_pattern + p_max_err + image_pattern_key = (image_pattern*p_bins).astype(int) + + image_patterns_evaluated += 1 + + # Possible range of pattern keys we need to look up + pattern_key_space_min = np.maximum(0, image_pattern_edge_ratio_min*p_bins).astype(int) + pattern_key_space_max = np.minimum(p_bins, image_pattern_edge_ratio_max*p_bins).astype(int) + # Make a list of the low/high values in each binned edge ratio position. + pattern_key_range = list(range(low, high + 1) for (low, high) in zip( + pattern_key_space_min, pattern_key_space_max)) + def dist(pattern_key): + return sum((a-b)*(a-b) for (a, b) in zip(pattern_key, image_pattern_key)) + + # Make a list of all pattern keys to explore; tag each with its distance from + # 'image_pattern_key' for sorting, so the first pattern key values we try are the + # ones closest to what we measured in the image to be solved. + pattern_key_list = list((dist(code), code) for code in itertools.product( + *pattern_key_range)) + pattern_key_list.sort() + + # Iterate over pattern keys, starting from 'image_pattern_key' and working + # our way outward. + for (_, pattern_key) in pattern_key_list: + search_space_explored += 1 + # Calculate corresponding hash index. + pattern_key_hash = _compute_pattern_key_hash(pattern_key, p_bins) + hash_index = _pattern_key_hash_to_index( + pattern_key_hash, self.pattern_catalog.shape[0], linear_probe) + + (catalog_pattern_edges, all_catalog_pattern_vectors) = \ + self._get_all_patterns_for_index( + pattern_key_hash, hash_index, upper_tri_index, + image_pattern_largest_edge, fov_estimate, + fov_max_error, linear_probe) + if catalog_pattern_edges is None: + continue + catalog_lookup_count += len(catalog_pattern_edges) + + all_catalog_largest_edges = catalog_pattern_edges[:, -1] + all_catalog_edge_ratios = (catalog_pattern_edges[:, :-1] / + all_catalog_largest_edges[:, None]) + + # Compare catalogue edge ratios to the min/max range from the image pattern. + valid_patterns = np.argwhere(np.all(np.logical_and( + image_pattern_edge_ratio_min < all_catalog_edge_ratios, + image_pattern_edge_ratio_max > all_catalog_edge_ratios), axis=1)).flatten() + + # Go through each matching pattern and calculate further + for index in valid_patterns: + catalog_eval_count += 1 + + # Compute the FOV that our image_pattern would yield if it were to + # match this pattern. + catalog_largest_edge = all_catalog_largest_edges[index] + if fov_estimate is not None: + # Can quickly correct FOV by scaling given estimate + fov = catalog_largest_edge / image_pattern_largest_edge * fov_initial + else: + # Use camera projection to calculate coarse fov + # The FOV estimate will be the same for each attempt with this pattern + # so we can cache the value by checking if we have already set it + if image_pattern_largest_distance is None: + image_pattern_largest_distance = np.max( + pdist(image_centroids_undist[image_pattern_indices, :])) + f = image_pattern_largest_distance / 2 / np.tan(catalog_largest_edge/2) + fov = 2*np.arctan(width/2/f) + + # Recalculate vectors using coarse FOV and uniquely sort them by + # distance from centroid + image_pattern_vectors = _compute_vectors( + image_centroids_undist[image_pattern_indices, :], (height, width), fov) + # find the centroid, or average position, of the star pattern + pattern_centroid = np.mean(image_pattern_vectors, axis=0) + # calculate each star's radius, or Euclidean distance from the centroid + pattern_radii = cdist(image_pattern_vectors, pattern_centroid[None, :]).flatten() + # use the radii to uniquely order the pattern's star vectors so they can be + # matched with the catalog vectors + image_pattern_vectors = np.array(image_pattern_vectors)[np.argsort(pattern_radii)] + + # Now get pattern vectors from catalogue, and sort if necessary + catalog_pattern_vectors = all_catalog_pattern_vectors[index, :] + if not presorted: + # find the centroid, or average position, of the star pattern + catalog_centroid = np.mean(catalog_pattern_vectors, axis=0) + # calculate each star's radius, or Euclidean distance from the centroid + catalog_radii = cdist(catalog_pattern_vectors, + catalog_centroid[None, :]).flatten() + # use the radii to uniquely order the catalog vectors + catalog_pattern_vectors = catalog_pattern_vectors[np.argsort(catalog_radii)] + + # Use the pattern match to find an estimate for the image's rotation matrix + rotation_matrix = _find_rotation_matrix(image_pattern_vectors, + catalog_pattern_vectors) + if np.linalg.det(rotation_matrix) < 0: + # Reject false positive due to implausible rotation matrix. + continue + + # Find all catalog star vectors inside the (diagonal) field of view for + # matching, in catalog brightness order. + image_center_vector = rotation_matrix[0, :] + fov_diagonal_rad = fov * np.sqrt(width**2 + height**2) / width + nearby_cat_star_inds = self._get_nearby_catalog_stars( + image_center_vector, fov_diagonal_rad/2) + nearby_cat_star_vectors = self.star_table[nearby_cat_star_inds, 2:5] + + # Derotate nearby catalog stars and get their (undistorted) centroids using + # coarse fov. + nearby_cat_star_vectors_derot = np.dot(rotation_matrix, + nearby_cat_star_vectors.T).T + (nearby_cat_star_centroids, kept) = _compute_centroids( + nearby_cat_star_vectors_derot, (height, width), fov) + nearby_cat_star_centroids = nearby_cat_star_centroids[kept, :] + nearby_cat_star_vectors = nearby_cat_star_vectors[kept, :] + nearby_cat_star_inds = nearby_cat_star_inds[kept] + # Only keep as many nearby stars as the image centroids. The 2x "fudge factor" + # is because image centroids brightness rankings might not match the nearby star + # catalog brightness rankings, so keeping some extra nearby stars helps ensure + # more matches. + nearby_cat_star_centroids = nearby_cat_star_centroids[:2*num_centroids] + nearby_cat_star_vectors = nearby_cat_star_vectors[:2*num_centroids] + nearby_cat_star_inds = nearby_cat_star_inds[:2*num_centroids] + num_nearby_catalog_stars = len(nearby_cat_star_centroids) + + # Match the image centroids to the nearby star centroids. + matched_stars = _find_centroid_matches( + image_centroids_undist, nearby_cat_star_centroids, width*match_radius) + num_extracted_stars = num_centroids + num_star_matches = len(matched_stars) + self._logger.debug("Number of nearby stars: %d, total matched: %d" \ + % (num_nearby_catalog_stars, num_star_matches)) + + # Probability that a single star is a mismatch (fraction of FOV area + # that are stars) + prob_single_star_mismatch = num_nearby_catalog_stars * match_radius**2 + # Probability that this rotation matrix's set of matches happen randomly + # we subtract two degrees of freedom + prob_mismatch = scipy.stats.binom.cdf(num_extracted_stars - (num_star_matches - 2), + num_extracted_stars, + 1 - prob_single_star_mismatch) + self._logger.debug("Mismatch probability = %.2e, at FOV = %.5fdeg" \ + % (prob_mismatch, np.rad2deg(fov))) + if prob_mismatch >= match_threshold: + continue + + # display mismatch probability in scientific notation + self._logger.debug("MATCH ACCEPTED") + self._logger.debug("Prob: %.4g, corr: %.4g" + % (prob_mismatch, prob_mismatch*self.num_patterns)) + + # Get the vectors for all matches in the image using coarse fov + matched_image_centroids_undist = image_centroids_undist[matched_stars[:, 0], :] + matched_image_vectors = _compute_vectors(matched_image_centroids_undist, + (height, width), fov) + matched_catalog_vectors = nearby_cat_star_vectors[matched_stars[:, 1], :] + # Recompute rotation matrix for more accuracy. The earlier rotation + # matrix was calculated using the pattern stars; the recomputed rotation + # matrix uses all star matches, not just the pattern stars. + rotation_matrix = _find_rotation_matrix(matched_image_vectors, + matched_catalog_vectors) + # Extract right ascension, declination, and roll from rotation matrix. + ra = np.rad2deg(np.arctan2(rotation_matrix[0, 1], + rotation_matrix[0, 0])) % 360 + dec = np.rad2deg(np.arctan2(rotation_matrix[0, 2], + norm(rotation_matrix[1:3, 2]))) + roll = np.rad2deg(np.arctan2(rotation_matrix[1, 2], + rotation_matrix[2, 2])) % 360 + + if distortion is None: + # Compare mutual angles in catalogue to those with current + # FOV estimate in order to scale accurately for fine FOV + angles_camera = _angle_from_distance(pdist(matched_image_vectors)) + angles_catalogue = _angle_from_distance(pdist(matched_catalog_vectors)) + fov *= np.mean(angles_catalogue / angles_camera) + k = None + else: + # Accurately calculate the FOV and distortion by looking at the angle + # from boresight on all matched catalogue vectors and all matched + # image centroids + matched_catalog_vectors_derot = np.dot( + rotation_matrix, matched_catalog_vectors.T).T + tangent_matched_catalog_vectors = norm( + matched_catalog_vectors_derot[:, 1:], axis=1) \ + /matched_catalog_vectors_derot[:, 0] + # Get the (distorted) pixel distance from image centre for all matches + # (scaled relative to width/2) + matched_image_centroids = image_centroids[matched_stars[:, 0], :] + radius_matched_image_centroids = norm(matched_image_centroids + - [height/2, width/2], axis=1)/width*2 + # Solve system of equations in RMS sense for focal length f and distortion k + # where f is focal length in units of image width/2 + # and k is distortion at width/2 (negative is barrel) + # undistorted = distorted*(1 - k*(distorted*2/width)^2) + A = np.hstack((tangent_matched_catalog_vectors[:, None], + radius_matched_image_centroids[:, None]**3)) + b = radius_matched_image_centroids[:, None] + (f, k) = lstsq(A, b, rcond=None)[0].flatten() + # Correct focal length to be at horizontal FOV + f = f/(1 - k) + self._logger.debug('Calculated focal length to %.2f and distortion to %.3f' % + (f, k)) + # Calculate (horizontal) true field of view + fov = 2*np.arctan(1/f) + # Re-undistort centroids using updated distortion for final calculations + image_centroids_undist = _undistort_centroids(image_centroids, + (height, width), k) + matched_image_centroids_undist = image_centroids_undist[ + matched_stars[:, 0], :] + + # Re-apply refined rotation matrix and FOV to nearby_cat_star_vectors. + nearby_cat_star_vectors_derot = np.dot(rotation_matrix, + nearby_cat_star_vectors.T).T + (nearby_cat_star_centroids, kept) = _compute_centroids( + nearby_cat_star_vectors_derot, (height, width), fov) + + # Get vectors + final_match_vectors = _compute_vectors( + matched_image_centroids_undist, (height, width), fov) + # Rotate to the sky + final_match_vectors = np.dot(rotation_matrix.T, final_match_vectors.T).T + + # Calculate residual angles between image vectors and catalog vectors. + distance = norm(final_match_vectors - matched_catalog_vectors, axis=1) + distance.sort() + p90_index = int(0.9 * (len(distance)-1)) + p90_err_angle = np.rad2deg(_angle_from_distance(distance[p90_index])) * 3600 + max_err_angle = np.rad2deg(_angle_from_distance(distance[-1])) * 3600 + angle = _angle_from_distance(distance) + rms_err_angle = np.rad2deg(np.sqrt(np.mean(angle**2))) * 3600 + + # Solved in this time + t_solve = (precision_timestamp() - t0_solve)*1000 + solution_dict = {'RA': ra, 'Dec': dec, + 'Roll': roll, + 'FOV': np.rad2deg(fov), + 'distortion': k, + 'RMSE': rms_err_angle, + 'P90E': p90_err_angle, + 'MAXE': max_err_angle, + 'Matches': num_star_matches, + 'Prob': prob_mismatch*self.num_patterns, + 'epoch_equinox': self._db_props['epoch_equinox'], + 'epoch_proper_motion': self._db_props['epoch_proper_motion'], + 'T_solve': t_solve, + 'status': MATCH_FOUND} + + # If we were given target pixel(s), calculate their ra/dec + if target_pixel is not None: + self._logger.debug('Calculate RA/Dec for targets: %s' % target_pixel) + # Calculate the vector in the sky of the target pixel(s) + if k is not None: + target_pixel = _undistort_centroids(target_pixel, (height, width), k) + target_vector = _compute_vectors( + target_pixel, (height, width), fov) + rotated_target_vector = np.dot(rotation_matrix.T, target_vector.T).T + # Calculate and add RA/Dec to solution + target_ra = np.rad2deg(np.arctan2(rotated_target_vector[:, 1], + rotated_target_vector[:, 0])) % 360 + target_dec = 90 - np.rad2deg( + np.arccos(rotated_target_vector[:,2])) + + if target_ra.shape[0] > 1: + solution_dict['RA_target'] = target_ra.tolist() + solution_dict['Dec_target'] = target_dec.tolist() + else: + solution_dict['RA_target'] = target_ra[0] + solution_dict['Dec_target'] = target_dec[0] + + # If we were given target sky coord(s), calculate their image x/y if + # within FOV. + if target_sky_coord is not None: + self._logger.debug('Calculate y/x for sky targets: %s' % target_sky_coord) + target_sky_vectors = [] + for tsc in target_sky_coord: + ra = np.deg2rad(tsc[0]) + dec = np.deg2rad(tsc[1]) + target_sky_vectors.append([np.cos(ra) * np.cos(dec), + np.sin(ra) * np.cos(dec), + np.sin(dec)]) + target_sky_vectors = np.array(target_sky_vectors) + target_sky_vectors_derot = np.dot(rotation_matrix, target_sky_vectors.T).T + (target_centroids, kept) = _compute_centroids(target_sky_vectors_derot, + (height, width), fov) + if k is not None: + for ind in kept: + centroid = target_centroids[ind] + target_centroids[ind] = _distort_centroids( + [centroid], (height, width), k)[0] + target_y = [] + target_x = [] + for i in range(target_centroids.shape[0]): + if i in kept: + target_y.append(target_centroids[i][0]) + target_x.append(target_centroids[i][1]) + else: + target_y.append(None) + target_x.append(None) + if target_sky_coord.shape[0] > 1: + solution_dict['y_target'] = target_y + solution_dict['x_target'] = target_x + else: + solution_dict['y_target'] = target_y[0] + solution_dict['x_target'] = target_x[0] + + # If requested to return data about matches, append to dict + if return_matches: + match_data = self._get_matched_star_data( + image_centroids[matched_stars[:, 0]], + nearby_cat_star_inds[matched_stars[:, 1]]) + solution_dict.update(match_data) + + pattern_centroids = [] + for img_pat_ind in image_pattern_indices: + pattern_centroids.append(image_centroids[img_pat_ind]) + solution_dict.update({'pattern_centroids': pattern_centroids}) + + # If requested to return catalog stars in FOV, append to dict. + if return_catalog: + catalog_tuples = [] + for (i, centroid) in enumerate(nearby_cat_star_centroids): + star_ind = nearby_cat_star_inds[i] + ra = np.rad2deg(self.star_table[star_ind, 0]) + dec = np.rad2deg(self.star_table[star_ind, 1]) + mag = self.star_table[star_ind, 5] + (y, x) = centroid + if k is not None: + dist_centroid = _distort_centroids([centroid], (height, width), k) + (y, x) = dist_centroid[0] + catalog_tuples.append( (ra, dec, mag, y, x) ) + solution_dict.update({'catalog_stars': catalog_tuples}) + + # If requested to create a visualisation, do so and append + if return_visual: + self._logger.debug('Generating visualisation') + img = Image.new('RGB', (width, height)) + img_draw = ImageDraw.Draw(img) + # Make list of matched and not from catalogue + matched = matched_stars[:, 1] + not_matched = np.array([True]*len(nearby_cat_star_centroids)) + not_matched[matched] = False + not_matched = np.flatnonzero(not_matched) + + def draw_circle(centre, radius, **kwargs): + bbox = [centre[1] - radius, + centre[0] - radius, + centre[1] + radius, + centre[0] + radius] + img_draw.ellipse(bbox, **kwargs) + + for cent in image_centroids: + # Centroids with no/given distortion + draw_circle(cent, 2, fill='white') + for cent in image_centroids_undist: + # Image centroids with coarse distortion for matching + draw_circle(cent, 1, fill='darkorange') + for cent in image_centroids_undist[image_pattern_indices, :]: + # Make the pattern ones larger + draw_circle(cent, 3, outline='darkorange') + for cent in matched_image_centroids_undist: + # Centroid position with solution distortion + draw_circle(cent, 1, fill='green') + for match in matched: + # Green circle for succeessful match + draw_circle(nearby_cat_star_centroids[match], + width*match_radius, outline='green') + for match in not_matched: + # Red circle for failed match + draw_circle(nearby_cat_star_centroids[match], + width*match_radius, outline='red') + + solution_dict['visual'] = img + + if return_rotation_matrix: + solution_dict['rotation_matrix'] = rotation_matrix.tolist() + + self._logger.debug(solution_dict) + self._logger.debug( + 'For %d centroids, evaluated %s image patterns; searched %s pattern keys' % + (num_centroids, + image_patterns_evaluated, + search_space_explored)) + self._logger.debug( + 'Looked up/evaluated %s/%s catalog patterns' % + (catalog_lookup_count, catalog_eval_count)) + return solution_dict + # Close of image_pattern_indices loop + + # Failed to solve (or timeout or cancel), get time and return None + t_solve = (precision_timestamp() - t0_solve) * 1000 + self._logger.debug('FAIL: Did not find a match to the stars! It took ' + + str(round(t_solve)) + ' ms.') + self._logger.debug( + 'FAIL: For %d centroids, evaluated %s image patterns; searched %s pattern keys' % + (num_centroids, + image_patterns_evaluated, + search_space_explored)) + self._logger.debug( + 'FAIL: Looked up/evaluated %s/%s catalog patterns' % + (catalog_lookup_count, catalog_eval_count)) + return {'RA': None, 'Dec': None, 'Roll': None, 'FOV': None, 'distortion': None, + 'RMSE': None, 'P90E': None, 'MAXE': None, 'Matches': None, 'Prob': None, + 'epoch_equinox': None, 'epoch_proper_motion': None, 'T_solve': t_solve, + 'status': status} + + def cancel_solve(self): + """Signal that a currently running solve_from_image() or solve_from_centroids() should + terminate immediately. + If no solve_from_{image,centroids} is running, this call affects the next solve attempt. + """ + self._logger.debug('cancelling') + self._cancelled = True + + def _get_all_patterns_for_index(self, pattern_key_hash, hash_index, upper_tri_index, + image_pattern_largest_edge, fov_estimate, fov_max_error, + linear_probe): + """Returns (edges, vectors) for all pattern table entries for `hash_index`.""" + + # Iterate over table hash indices. + hash_match_inds = _get_table_indices_from_hash( + hash_index, self.pattern_catalog, linear_probe) + if len(hash_match_inds) == 0: + return (None, None) + + if self.pattern_key_hashes is not None: + key_hash16 = np.uint16(int(pattern_key_hash) & 0xffff) + keep = self.pattern_key_hashes[hash_match_inds] == key_hash16 + hash_match_inds = hash_match_inds[keep] + if len(hash_match_inds) == 0: + return (None, None) + + if self.pattern_largest_edge is not None \ + and fov_estimate is not None \ + and fov_max_error is not None: + # Can immediately compare FOV to patterns to remove mismatches + largest_edge = self.pattern_largest_edge[hash_match_inds].astype(np.float32) + fov2 = largest_edge / image_pattern_largest_edge * fov_estimate / 1000 + keep = abs(fov2 - fov_estimate) < fov_max_error + hash_match_inds = hash_match_inds[keep] + if len(hash_match_inds) == 0: + return (None, None) + catalog_matches = self.pattern_catalog[hash_match_inds, :] + + # Get star vectors for all matching hashes + catalog_pattern_vectors = self.star_table[catalog_matches, 2:5] + # Calculate pattern by angles between vectors + # implement more accurate angle calculation + # this is a bit manual, I could not see a faster way + arr1 = np.take(catalog_pattern_vectors, upper_tri_index[0], axis=1) + arr2 = np.take(catalog_pattern_vectors, upper_tri_index[1], axis=1) + catalog_pattern_edges = np.sort(_angle_from_distance(norm(arr1 - arr2, axis=-1))) + + return (catalog_pattern_edges, catalog_pattern_vectors) + + def _get_nearby_catalog_stars(self, vector, radius): + """Get star indices within radius radians of the vector. Sorted brightest first.""" + max_dist = _distance_from_angle(radius) + nearby = self._star_kd_tree.query_ball_point(vector, max_dist) + return np.sort(nearby) + + def _get_matched_star_data(self, centroid_data, star_indices): + """Get dictionary of matched star data to return. + + centroid_data: ndarray of centroid data Nx2, each row (y, x) + star_indices: ndarray of matching star indices len N + + return dict with keys: + - matched_centroids: Nx2 (y, x) in pixel coordinates, sorted by brightness + - matched_stars: Nx3 (ra (deg), dec (deg), magnitude) + - matched_catID: (N,) or (N, 3) with catalogue ID + """ + output = {} + output['matched_centroids'] = centroid_data.tolist() + stars = self.star_table[star_indices, :][:, [0, 1, 5]] + stars[:,:2] = np.rad2deg(stars[:,:2]) + output['matched_stars'] = stars.tolist() + if self.star_catalog_IDs is None: + output['matched_catID'] = None + elif len(self.star_catalog_IDs.shape) > 1: + # Have 2D array, pick rows + output['matched_catID'] = self.star_catalog_IDs[star_indices, :].tolist() + else: + # Have 1D array, pick indices + output['matched_catID'] = self.star_catalog_IDs[star_indices].tolist() + return output + + @staticmethod + def _build_catalog_path(star_catalog): + """ build the path to the star catalog and parse the catalog name + Args: + star_catalog (str or pathlib.Path, optional): the name or path to the star catalog file + Returns: + (tuple[str, pathlib.Path]): return the pure catalog name and the file path + """ + if star_catalog in _supported_databases: + # only name supplied, assume file is adjacent to this code file + catalog_file_full_pathname = _lib_root / star_catalog + else: + # a path string or path object supplied, parse out the pure name + catalog_file_full_pathname = Path(star_catalog).expanduser() + star_catalog = catalog_file_full_pathname.name.rstrip(catalog_file_full_pathname.suffix) + + if star_catalog not in _supported_databases: + raise ValueError( + f"star_catalog name must be one of {_supported_databases}, got: {star_catalog}") + + # Add .dat suffix for hip and tyc if not present + if star_catalog in ('hip_main', 'tyc_main') and not catalog_file_full_pathname.suffix: + catalog_file_full_pathname = catalog_file_full_pathname.with_suffix('.dat') + + if not catalog_file_full_pathname.exists(): + raise ValueError(f'No star catalogue found at {str(catalog_file_full_pathname)}') + + return star_catalog, catalog_file_full_pathname + +# celestial_coords: [[ra, dec], ...] in degrees +# returns: [[y, x], ...] +def transform_to_image_coords(celestial_coords, width, height, fov, + rotation_matrix, distortion): + rotation_matrix = np.array(rotation_matrix) + celestial_vectors = [] + for cc in celestial_coords: + ra = np.deg2rad(cc[0]) + dec = np.deg2rad(cc[1]) + celestial_vectors.append([np.cos(ra) * np.cos(dec), + np.sin(ra) * np.cos(dec), + np.sin(dec)]) + celestial_vectors = np.array(celestial_vectors) + celestial_vectors_derot = np.dot(rotation_matrix, celestial_vectors.T).T + (image_coords, kept) = _compute_centroids( + celestial_vectors_derot, (height, width), np.deg2rad(fov)) + image_coords = _distort_centroids(image_coords, (height, width), distortion) + result = [] + for i in range(image_coords.shape[0]): + if i in kept: + result.append(image_coords[i]) + return result + +# image_coords: [[y, x], ...] +# returns: [[ra, dec], ...] in degrees +def transform_to_celestial_coords(image_coords, width, height, fov, + rotation_matrix, distortion): + rotation_matrix = np.array(rotation_matrix) + + image_coords = np.array(image_coords) + image_coords = _undistort_centroids(image_coords, (height, width), distortion) + image_vectors = _compute_vectors(image_coords, (height, width), np.deg2rad(fov)) + rotated_image_vectors = np.dot(rotation_matrix.T, image_vectors.T).T + + # Calculate and add RA/Dec to solution + ra = np.rad2deg(np.arctan2(rotated_image_vectors[:, 1], + rotated_image_vectors[:, 0])) % 360 + dec = 90 - np.rad2deg(np.arccos(rotated_image_vectors[:,2])) + + celestial_vectors = [] + for i in range(len(ra)): + celestial_vectors.append((ra[i], dec[i])) + + return celestial_vectors + + +def get_centroids_from_image(image, sigma=2, image_th=None, crop=None, downsample=None, + filtsize=25, bg_sub_mode='local_mean', sigma_mode='global_root_square', + binary_open=True, centroid_window=None, max_area=100, min_area=5, + max_sum=None, min_sum=None, max_axis_ratio=None, max_returned=None, + return_moments=False, return_images=False): + """Extract spot centroids from an image and calculate statistics. + + This is a versatile function for finding spots (e.g. stars or satellites) in an image and + calculating/filtering their positions (centroids) and statistics (e.g. sum, area, shape). + + The coordinates start at the top/left edge of the pixel, i.e. x=y=0.5 is the centre of the + top-left pixel. To convert the results to integer pixel indices use the floor operator. + + To aid in finding optimal settings pass `return_images=True` to get back a dictionary with + partial extraction results and tweak the parameters accordingly. The dictionary entry + `binary_mask` shows the result of the raw star detection and `final_centroids` labels the + centroids in the original image (green for accepted, red for rejected). + + Technically, the best extraction is attained with `bg_sub_mode='local_median'` and + `sigma_mode='local_median_abs'` with a reasonable (e.g. 15) size filter and a very sharp image. + However, this may be slow (especially for larger filter sizes) and requires that the camera + readout bit-depth is sufficient to accurately capture the camera noise. A recommendable and + much faster alternative is `bg_sub_mode='local_mean'` and `sigma_mode='global_root_square'` + with a larger (e.g. 25 or more) sized filter, which is the default. You may elect to do + background subtraction and image thresholding by your own methods, then pass `bg_sub_mode=None` + and your threshold as `image_th` to bypass these extraction steps. + + The algorithm proceeds as follows: + 1. Convert image to 2D numpy.ndarray with type float32. + 2. Call :meth:`tetra3.crop_and_downsample_image` with the image and supplied arguments + `crop` and `downsample`. + 3. Subtract the background if `bg_sub_mode` is not None. Four methods are available: + + - 'local_median': Create the background image using a median filter of + size `filtsize` and subtract pixelwise. + - 'local_mean' (the default): Create the background image using a mean filter of size + `filtsize` and subtract pixelwise. + - 'global_median': Subtract the median value of all pixels from each pixel. + - 'global_mean': Subtract the mean value of all pixels from each pixel. + + 4. Calculate the image threshold if image_th is None. If image_th is defined this value + will be used to threshold the image. The threshold is determined by calculating the + noise standard deviation with the method selected as `sigma_mode` and then scaling it by + `sigma` (default 3). The available methods are: + + - 'local_median_abs': For each pixel, calculate the standard deviation as + the median of the absolute values in a region of size `filtsize` and scale by 1.48. + - 'local_root_square': For each pixel, calculate the standard deviation as the square + root of the mean of the square values in a region of size `filtsize`. + - 'global_median_abs': Use the median of the absolute value of all pixels scaled by 1.48 + as the standard deviation. + - 'global_root_square' (the default): Use the square root of the mean of the square of + all pixels as the standard deviation. + + 5. Create a binary mask using the image threshold. If `binary_open=True` (the default) + apply a binary opening operation with a 3x3 cross as structuring element to clean up the + mask. + 6. Label all regions (spots) in the binary mask. + 7. Calculate statistics on each region and reject it if it fails any of the max or min + values passed. Calculated statistics are: area, sum, centroid (first moments) in x and + y, second moments in xx, yy, and xy, major over minor axis ratio. + 8. Sort the regions, largest sum first, and keep at most `max_returned` if not None. + 9. If `centroid_window` is not None, recalculate the statistics using a square region of + the supplied width (instead of the region from the binary mask). + 10. Undo the effects of cropping and downsampling by adding offsets/scaling the centroid + positions to correspond to pixels in the original image. + + Args: + image (PIL.Image): Image to find centroids in. + sigma (float, optional): The number of noise standard deviations to threshold at. + Default 2. + image_th (float, optional): The value to threshold the image at. If supplied `sigma` and + `simga_mode` will have no effect. + crop (tuple, optional): Cropping to apply, see :meth:`tetra3.crop_and_downsample_image`. + downsample (int, optional): Downsampling to apply, see + :meth:`tetra3.crop_and_downsample_image`. + filtsize (int, optional): Size of filter to use in local operations. Must be odd. + Default 25. + bg_sub_mode (str, optional): Background subtraction mode. Must be one of 'local_median', + 'local_mean' (the default), 'global_median', 'global_mean'. + sigma_mode (str, optinal): Mode used to calculate noise standard deviation. Must be one of + 'local_median_abs', 'local_root_square', 'global_median_abs', or + 'global_root_square' (the default). + binary_open (bool, optional): If True (the default), apply binary opening with 3x3 cross + to thresholded binary mask. + centroid_window (int, optional): If supplied, recalculate statistics using a square window + of the supplied size. + max_area (int, optional): Reject spots larger than this. Defaults to 100 pixels. + min_area (int, optional): Reject spots smaller than this. Defaults to 5 pixels. + max_sum (float, optional): Reject spots with a sum larger than this. Defaults to None. + min_sum (float, optional): Reject spots with a sum smaller than this. Defaults to None. + max_axis_ratio (float, optional): Reject spots with a ratio of major over minor axis larger + than this. Defaults to None. + max_returned (int, optional): Return at most this many spots. Defaults to None, which + returns all spots. Will return in order of brightness (spot sum). + return_moments (bool, optional): If set to True, return the calculated statistics (e.g. + higher order moments, sum, area) together with the spot positions. + return_images (bool, optional): If set to True, return a dictionary with partial results + from the steps in the algorithm. + + Returns: + numpy.ndarray or tuple: If `return_moments=False` and `return_images=False` (the defaults) + an array of shape (N,2) is returned with centroid positions (y down, x right) of the + found spots in order of brightness. If `return_moments=True` a tuple of numpy arrays + is returned with: (N,2) centroid positions, N sum, N area, (N,3) xx yy and xy second + moments, N major over minor axis ratio. If `return_images=True` a tuple is returned + with the results as defined previously and a dictionary with images and data of partial + results. The keys are: `converted_input`: The input after conversion to a mono float + numpy array. `cropped_and_downsampled`: The image after cropping and downsampling. + `removed_background`: The image after background subtraction. `binary_mask`: The + thresholded image where raw stars are detected (after binary opening). + `final_centroids`: The original image annotated with green circles for the extracted + centroids, and red circles for any centroids that were rejected. + """ + + # 1. Ensure image is float np array and 2D: + raw_image = image.copy() + image = np.asarray(image, dtype=np.float32) + if image.ndim == 3: + assert image.shape[2] in (1, 3), 'Colour image must have 1 or 3 colour channels' + if image.shape[2] == 3: + # Convert to greyscale + image = image[:, :, 0]*.299 + image[:, :, 1]*.587 + image[:, :, 2]*.114 + else: + # Delete empty dimension + image = image.squeeze(axis=2) + else: + assert image.ndim == 2, 'Image must be 2D or 3D array' + if return_images: + images_dict = {'converted_input': image.copy()} + # 2 Crop and downsample + (image, offs) = crop_and_downsample_image(image, crop=crop, downsample=downsample, + return_offsets=True, sum_when_downsample=True) + (height, width) = image.shape + (offs_h, offs_w) = offs + if return_images: + images_dict['cropped_and_downsampled'] = image.copy() + # 3. Subtract background: + if bg_sub_mode is not None: + if bg_sub_mode.lower() == 'local_median': + assert filtsize is not None, \ + 'Must define filter size for local median background subtraction' + assert filtsize % 2 == 1, 'Filter size must be odd' + image = image - scipy.ndimage.filters.median_filter(image, size=filtsize, + output=image.dtype) + elif bg_sub_mode.lower() == 'local_mean': + assert filtsize is not None, \ + 'Must define filter size for local median background subtraction' + assert filtsize % 2 == 1, 'Filter size must be odd' + image = image - scipy.ndimage.filters.uniform_filter(image, size=filtsize, + output=image.dtype) + elif bg_sub_mode.lower() == 'global_median': + image = image - np.median(image) + elif bg_sub_mode.lower() == 'global_mean': + image = image - np.mean(image) + else: + raise AssertionError('bg_sub_mode must be string: local_median, local_mean,' + + ' global_median, or global_mean') + if return_images: + images_dict['removed_background'] = image.copy() + # 4. Find noise standard deviation to threshold unless a threshold is already defined! + if image_th is None: + assert sigma_mode is not None and isinstance(sigma_mode, str), \ + 'Must define a sigma mode or image threshold' + assert sigma is not None and isinstance(sigma, (int, float)), \ + 'Must define sigma for thresholding (int or float)' + if sigma_mode.lower() == 'local_median_abs': + assert filtsize is not None, 'Must define filter size for local median sigma mode' + assert filtsize % 2 == 1, 'Filter size must be odd' + img_std = scipy.ndimage.filters.median_filter(np.abs(image), size=filtsize, + output=image.dtype) * 1.48 + elif sigma_mode.lower() == 'local_root_square': + assert filtsize is not None, 'Must define filter size for local median sigma mode' + assert filtsize % 2 == 1, 'Filter size must be odd' + img_std = np.sqrt(scipy.ndimage.filters.uniform_filter(image**2, size=filtsize, + output=image.dtype)) + elif sigma_mode.lower() == 'global_median_abs': + img_std = np.median(np.abs(image)) * 1.48 + elif sigma_mode.lower() == 'global_root_square': + img_std = np.sqrt(np.mean(image**2)) + else: + raise AssertionError('sigma_mode must be string: local_median_abs, local_root_square,' + + ' global_median_abs, or global_root_square') + image_th = img_std * sigma + #if return_images: + # images_dict['image_threshold'] = image_th + # 5. Threshold to find binary mask + bin_mask = image > image_th + if binary_open: + bin_mask = scipy.ndimage.binary_opening(bin_mask) + if return_images: + images_dict['binary_mask'] = bin_mask + # 6. Label each region in the binary mask + (labels, num_labels) = scipy.ndimage.label(bin_mask) + index = np.arange(1, num_labels + 1) + #if return_images: + # images_dict['labelled_regions'] = labels + if num_labels < 1: + # Found nothing in binary image, return empty. + if return_moments and return_images: + return ((np.empty((0, 2)), np.empty((0, 1)), np.empty((0, 1)), np.empty((0, 3)), + np.empty((0, 1))), images_dict) + elif return_moments: + return (np.empty((0, 2)), np.empty((0, 1)), np.empty((0, 1)), np.empty((0, 3)), + np.empty((0, 1))) + elif return_images: + return (np.empty((0, 2)), images_dict) + else: + return np.empty((0, 2)) + + # 7. Get statistics and threshold + def calc_stats(a, p): + """Calculates statistics for each labelled region: + - Sum (zeroth moment) + - Centroid y, x (first moment) + - Variance xx, yy, xy (second moment) + - Area (pixels) + - Major axis/minor axis ratio + First variable will be NAN if failed any of the checks + """ + (y, x) = (np.unravel_index(p, (height, width))) + area = len(a) + centroid = np.sum([a, x*a, y*a], axis=-1) + m0 = centroid[0] + centroid[1:] = centroid[1:] / m0 + m1_x = centroid[1] + m1_y = centroid[2] + # Check basic filtering + if min_area and area < min_area: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + if max_area and area > max_area: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + if min_sum and m0 < min_sum: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + if max_sum and m0 > max_sum: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + # If higher order data is requested or used for filtering, calculate. + if return_moments or max_axis_ratio is not None: + # Need to calculate second order data about the regions, firstly the moments + # then use that to get major/minor axes. + m2_xx = max(0, np.sum((x - m1_x)**2 * a) / m0) + m2_yy = max(0, np.sum((y - m1_y)**2 * a) / m0) + m2_xy = np.sum((x - m1_x) * (y - m1_y) * a) / m0 + major = np.sqrt(2 * (m2_xx + m2_yy + np.sqrt((m2_xx - m2_yy)**2 + 4 * m2_xy**2))) + minor = np.sqrt(2 * max(0, m2_xx + m2_yy - np.sqrt((m2_xx - m2_yy)**2 + 4 * m2_xy**2))) + if max_axis_ratio and minor <= 0: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + axis_ratio = major / max(minor, .000000001) + if max_axis_ratio and axis_ratio > max_axis_ratio: + return (np.nan, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, np.nan, np.nan) + return (m0, m1_y+.5, m1_x+.5, m2_xx, m2_yy, m2_xy, area, axis_ratio) + else: + return (m0, m1_y+.5, m1_x+.5, np.nan, np.nan, np.nan, area, np.nan) + + tmp = scipy.ndimage.labeled_comprehension(image, labels, index, calc_stats, '8f', None, + pass_positions=True) + valid = ~np.isnan(tmp[:, 0]) + extracted = tmp[valid, :] + rejected = tmp[~valid, :] + if return_images: + # Convert 16-bit to 8-bit: + if raw_image.mode == 'I;16': + tmp = np.array(raw_image, dtype=np.uint16) + tmp //= 256 + tmp = tmp.astype(np.uint8) + raw_image = Image.fromarray(tmp) + # Convert mono to RGB + if raw_image.mode != 'RGB': + raw_image = raw_image.convert('RGB') + # Draw green circles for kept centroids, red for rejected + img_draw = ImageDraw.Draw(raw_image) + def draw_circle(centre, radius, **kwargs): + bbox = [centre[1] - radius, + centre[0] - radius, + centre[1] + radius, + centre[0] + radius] + img_draw.ellipse(bbox, **kwargs) + for entry in extracted: + pos = entry[1:3].copy() + size = .01*width + if downsample is not None: + pos *= downsample + pos += [offs_h, offs_w] + size *= downsample + draw_circle(pos, size, outline='green') + for entry in rejected: + pos = entry[1:3].copy() + size = .01*width + if downsample is not None: + pos *= downsample + pos += [offs_h, offs_w] + size *= downsample + draw_circle(pos, size, outline='red') + images_dict['final_centroids'] = raw_image + + # 8. Sort + order = (-extracted[:, 0]).argsort() + if max_returned: + order = order[:max_returned] + extracted = extracted[order, :] + # 9. If desired, redo centroiding with traditional window + if centroid_window is not None: + if centroid_window > min(height, width): + centroid_window = min(height, width) + for i in range(extracted.shape[0]): + c_x = int(np.floor(extracted[i, 2])) + c_y = int(np.floor(extracted[i, 1])) + offs_x = c_x - centroid_window // 2 + offs_y = c_y - centroid_window // 2 + if offs_y < 0: + offs_y = 0 + if offs_y > height - centroid_window: + offs_y = height - centroid_window + if offs_x < 0: + offs_x = 0 + if offs_x > width - centroid_window: + offs_x = width - centroid_window + img_cent = image[offs_y:offs_y + centroid_window, offs_x:offs_x + centroid_window] + img_sum = np.sum(img_cent) + (xx, yy) = np.meshgrid(np.arange(centroid_window) + .5, + np.arange(centroid_window) + .5) + xc = np.sum(img_cent * xx) / img_sum + yc = np.sum(img_cent * yy) / img_sum + extracted[i, 1:3] = np.array([yc, xc]) + [offs_y, offs_x] + # 10. Revert effects of crop and downsample + if downsample: + extracted[:, 1:3] = extracted[:, 1:3] * downsample # Scale centroid + if crop: + extracted[:, 1:3] = extracted[:, 1:3] + np.array([offs_h, offs_w]) # Offset centroid + # Return results, default just the centroids + if not any((return_moments, return_images)): + return extracted[:, 1:3] + # Otherwise, build list of requested returned items + result = [extracted[:, 1:3]] + if return_moments: + result.append([extracted[:, 0], extracted[:, 6], extracted[:, 3:6], + extracted[:, 7]]) + if return_images: + result.append(images_dict) + return tuple(result) + + +def crop_and_downsample_image(image, crop=None, downsample=None, sum_when_downsample=True, + return_offsets=False): + """Crop and/or downsample an image. Cropping is applied before downsampling. + + Args: + image (numpy.ndarray): The image to crop and downsample. Must be 2D. + crop (int or tuple, optional): Desired cropping of the image. May be defined in three ways: + + - Scalar: Image is cropped to given fraction (e.g. crop=2 gives 1/2 size image out). + - 2-tuple: Image is cropped to centered region with size crop = (height, width). + - 4-tuple: Image is cropped to region with size crop[0:2] = (height, width), offset + from the centre by crop[2:4] = (offset_down, offset_right). + + downsample (int, optional): Downsampling factor, e.g. downsample=2 will combine 2x2 pixel + regions into one. The image width and height must be divisible by this factor. + sum_when_downsample (bool, optional): If True (the default) downsampled pixels are + calculated by summing the original pixel values. If False the mean is used. + return_offsets (bool, optional): If set to True, the applied cropping offset from the top + left corner is returned. + Returns: + numpy.ndarray or tuple: If `return_offsets=False` (the default) a 2D array with the cropped + and dowsampled image is returned. If `return_offsets=True` is passed a tuple containing + the image and a tuple with the cropping offsets (top, left) is returned. + """ + # Input must be 2-d numpy array + # Crop can be either a scalar, 2-tuple, or 4-tuple: + # Scalar: Image is cropped to given fraction (eg input crop=2 gives 1/2 size image out) + # If 2-tuple: Image is cropped to center region with size crop = (height, width) + # If 4-tuple: Image is cropped to ROI with size crop[0:1] = (height, width) + # offset from centre by crop[2:3] = (offset_down, offset_right) + # Downsample is made by summing regions of downsample by downsample pixels. + # To get the mean set sum_when_downsample=False. + # Returned array is same type as input array! + + image = np.asarray(image) + assert image.ndim == 2, 'Input must be 2D' + # Do nothing if both are None + if crop is None and downsample is None: + if return_offsets is True: + return (image, (0, 0)) + else: + return image + full_height, full_width = image.shape + # Check if input is integer type (and therefore can overflow...) + if np.issubdtype(image.dtype, np.integer): + intype = image.dtype + else: + intype = None + # Crop: + if crop is not None: + try: + # Make crop into list of int + crop = [int(x) for x in crop] + if len(crop) == 2: + crop = crop + [0, 0] + elif len(crop) == 4: + pass + else: + raise ValueError('Length of crop must be 2 or 4 if iterable, not ' + + str(len(crop)) + '.') + except TypeError: + # Could not make list (i.e. not iterable input), crop to portion + crop = int(crop) + assert crop > 0, 'Crop must be greater than zero if scalar.' + assert full_height % crop == 0 and full_width % crop == 0,\ + 'Crop must be divisor of image height and width if scalar.' + crop = [full_height // crop, full_width // crop, 0, 0] + # Calculate new height and width (making sure divisible with future downsampling) + divisor = downsample if downsample is not None else 2 + height = int(np.ceil(crop[0]/divisor)*divisor) + width = int(np.ceil(crop[1]/divisor)*divisor) + # Clamp at original size + if height > full_height: + height = full_height + if width > full_width: + width = full_width + # Calculate offsets from centre + offs_h = int(round(crop[2] + (full_height - height)/2)) + offs_w = int(round(crop[3] + (full_width - width)/2)) + # Clamp to be inside original image + if offs_h < 0: + offs_h = 0 + if offs_h > full_height-height: + offs_h = full_height-height + if offs_w < 0: + offs_w = 0 + if offs_w > full_width-width: + offs_w = full_width-width + # Do the cropping + image = image[offs_h:offs_h+height, offs_w:offs_w+width] + else: + offs_h = 0 + offs_w = 0 + height = full_height + width = full_width + # Downsample: + if downsample is not None: + assert height % downsample == 0 and width % downsample == 0,\ + '(Cropped) image must be divisible by downsampling factor' + if intype is not None: + # Convert integer types into float for summing without overflow risk + image = image.astype(np.float32) + if sum_when_downsample is True: + image = image.reshape((height//downsample, downsample, width//downsample, + downsample)).sum(axis=-1).sum(axis=1) + else: + image = image.reshape((height//downsample, downsample, width//downsample, + downsample)).mean(axis=-1).mean(axis=1) + if intype is not None: + # Convert back with clipping + image = image.clip(np.iinfo(intype).min, np.iinfo(intype).max).astype(intype) + # Return image and if desired the offset. + if return_offsets is True: + return (image, (offs_h, offs_w)) + else: + return image diff --git a/ogscope/web/api/alignment/routes.py b/ogscope/web/api/alignment/routes.py index 405c949..be904f4 100644 --- a/ogscope/web/api/alignment/routes.py +++ b/ogscope/web/api/alignment/routes.py @@ -1,66 +1,34 @@ """ -极轴校准相关API路由 +极轴校准相关API路由 / Polar alignment API routes """ + from fastapi import APIRouter -from ogscope.web.api.models.schemas import PolarAlignStatus, AlignmentStatus router = APIRouter() -@router.post("/polar-align/start") -async def start_polar_alignment(): - """开始极轴校准""" - # TODO: 实现极轴校准启动 - return { - "success": True, - "message": "极轴校准已启动", - } - - @router.post("/alignment/start") async def start_alignment(): - """开始极轴校准""" - # TODO: 实现极轴校准启动逻辑 + """开始极轴校准 / Start polar alignment""" + # TODO: 实现极轴校准启动逻辑 / TODO: Implement polar axis calibration startup logic return {"status": "success", "message": "极轴校准已开始"} @router.post("/alignment/stop") async def stop_alignment(): - """停止极轴校准""" - # TODO: 实现极轴校准停止逻辑 + """停止极轴校准 / Stop polar calibration""" + # TODO: 实现极轴校准停止逻辑 / TODO: Implement polar axis calibration stop logic return {"status": "success", "message": "极轴校准已停止"} @router.get("/alignment/status") async def get_alignment_status(): - """获取极轴校准状态""" - # TODO: 实现极轴校准状态获取逻辑 + """获取极轴校准状态 / Get polar calibration status""" + # TODO: 实现极轴校准状态获取逻辑 / TODO: Implement polar axis calibration status acquisition logic return { "status": "running", "azimuth_error": 2.5, "altitude_error": 1.8, "precision": "good", - "progress": 75 - } - - -@router.get("/polar-align/status") -async def get_polar_align_status() -> PolarAlignStatus: - """获取极轴校准状态""" - # TODO: 实现状态获取 - return PolarAlignStatus( - is_running=False, - progress=0.0, - azimuth_error=0.0, - altitude_error=0.0, - ) - - -@router.post("/polar-align/stop") -async def stop_polar_alignment(): - """停止极轴校准""" - # TODO: 实现极轴校准停止 - return { - "success": True, - "message": "极轴校准已停止", + "progress": 75, } diff --git a/ogscope/web/api/analysis/__init__.py b/ogscope/web/api/analysis/__init__.py new file mode 100644 index 0000000..fd95b96 --- /dev/null +++ b/ogscope/web/api/analysis/__init__.py @@ -0,0 +1,3 @@ +""" +分析 API 包 / Analysis API package +""" diff --git a/ogscope/web/api/analysis/lab_store.py b/ogscope/web/api/analysis/lab_store.py new file mode 100644 index 0000000..dad97e1 --- /dev/null +++ b/ogscope/web/api/analysis/lab_store.py @@ -0,0 +1,332 @@ +""" +星图解算实验室:清单、预设、实验记录文件存储 / Lab manifest, presets, experiment records. +""" + +from __future__ import annotations + +import base64 +import csv +import hashlib +import io +import json +import uuid +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from ogscope.config import Settings + + +def _utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +class AnalysisLabStore: + """实验室侧持久化 / Lab persistence (JSON files).""" + + def __init__(self, settings: Settings) -> None: + self._settings = settings + self.upload_root = settings.upload_dir / "analysis" + self.presets_official = settings.data_dir / "analysis" / "presets" / "official" + self.presets_user = settings.data_dir / "analysis" / "presets" / "user" + self.experiments_root = settings.analysis_dir / "experiments" + for p in ( + self.upload_root, + self.presets_official, + self.presets_user, + self.experiments_root, + ): + p.mkdir(parents=True, exist_ok=True) + + @property + def manifest_path(self) -> Path: + return self.upload_root / "manifest.json" + + def load_manifest(self) -> dict[str, Any]: + """加载上传目录清单 / Load upload manifest.""" + if not self.manifest_path.is_file(): + return {"version": 1, "entries": {}} + try: + data = json.loads(self.manifest_path.read_text(encoding="utf-8")) + if not isinstance(data, dict) or "entries" not in data: + return {"version": 1, "entries": {}} + return data + except Exception: + return {"version": 1, "entries": {}} + + def save_manifest(self, data: dict[str, Any]) -> None: + self.manifest_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + def set_file_source(self, filename: str, source: str) -> None: + """设置素材来源标签 / Set asset source tag.""" + m = self.load_manifest() + entries: dict[str, Any] = m.setdefault("entries", {}) + ent = entries.setdefault(filename, {}) + ent["source"] = source + ent["updated_at"] = _utc_now() + self.save_manifest(m) + + def update_last_solve( + self, + filename: str, + metrics: dict[str, Any], + ) -> None: + """写入最近一次解算摘要 / Cache last solve summary for list UI.""" + m = self.load_manifest() + entries: dict[str, Any] = m.setdefault("entries", {}) + ent = entries.setdefault(filename, {}) + ent["last_solve"] = {**metrics, "at": _utc_now()} + self.save_manifest(m) + + def remove_manifest_entry(self, filename: str) -> None: + """从清单移除条目(删除文件后调用)/ Remove manifest row after file delete.""" + m = self.load_manifest() + entries: dict[str, Any] = m.setdefault("entries", {}) + if filename in entries: + del entries[filename] + self.save_manifest(m) + + def merge_list_entry(self, filename: str, base: dict[str, Any]) -> dict[str, Any]: + """合并清单元数据到列表项 / Merge manifest into upload list row.""" + m = self.load_manifest() + ent = m.get("entries", {}).get(filename, {}) + row = {**base} + if "source" in ent: + row["source"] = ent["source"] + else: + row["source"] = "unknown" + if "last_solve" in ent: + row["last_solve"] = ent["last_solve"] + return row + + def list_presets(self, scope: str) -> list[dict[str, Any]]: + """列出预设 JSON / List preset files.""" + root = self.presets_official if scope == "official" else self.presets_user + out: list[dict[str, Any]] = [] + if not root.is_dir(): + return out + for p in sorted(root.glob("*.json")): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict): + data.setdefault("id", p.stem) + data.setdefault("scope", scope) + out.append(data) + except Exception: + continue + return out + + def save_user_preset(self, name: str, params: dict[str, Any]) -> dict[str, Any]: + """保存用户预设 / Save user preset.""" + pid = str(uuid.uuid4()) + payload = { + "id": pid, + "name": name, + "scope": "user", + "params": params, + "created_at": _utc_now(), + } + target = self.presets_user / f"{pid}.json" + target.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return payload + + def delete_user_preset(self, preset_id: str) -> None: + """删除用户预设 / Delete user preset.""" + clean = Path(preset_id).name + target = self.presets_user / f"{clean}.json" + if target.is_file(): + target.unlink() + + def create_experiment( + self, + input_name: str, + preset_label: str, + result_json: dict[str, Any], + metrics: dict[str, Any], + thumbnail_png_base64: str | None, + replay: dict[str, Any] | None = None, + save_asset_snapshot: bool = True, + ) -> dict[str, Any]: + """写入实验记录 / Persist experiment record.""" + eid = str(uuid.uuid4()) + thumb_path: str | None = None + if thumbnail_png_base64: + raw = base64.b64decode( + thumbnail_png_base64.split(",")[-1] + if "," in thumbnail_png_base64 + else thumbnail_png_base64 + ) + thumb_path = str(self.experiments_root / f"{eid}.png") + Path(thumb_path).write_bytes(raw) + asset_snapshot_relpath: str | None = None + asset_digest: str | None = None + src = (self.upload_root / Path(input_name).name).resolve() + root = self.upload_root.resolve() + if save_asset_snapshot and src.is_file() and str(src).startswith(str(root)): + try: + data = src.read_bytes() + asset_digest = hashlib.sha256(data).hexdigest() + ext = src.suffix if src.suffix else ".bin" + asset_snapshot_relpath = f"{eid}_asset{ext}" + (self.experiments_root / asset_snapshot_relpath).write_bytes(data) + except OSError: + asset_snapshot_relpath = None + asset_digest = None + rec = { + "id": eid, + "input_name": input_name, + "preset_label": preset_label, + "created_at": _utc_now(), + "metrics": metrics, + "result_json": result_json, + "thumbnail_relpath": Path(thumb_path).name if thumb_path else None, + "replay": replay, + "asset_snapshot_relpath": asset_snapshot_relpath, + "asset_digest": asset_digest, + } + (self.experiments_root / f"{eid}.json").write_text( + json.dumps(rec, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return rec + + def delete_experiment(self, experiment_id: str) -> None: + """删除一条实验记录 JSON、缩略图与素材快照 / Delete experiment artifacts.""" + clean = Path(experiment_id).name + if not clean or clean != experiment_id.strip(): + raise ValueError("实验 ID 无效 / Invalid experiment id") + jpath = self.experiments_root / f"{clean}.json" + if not jpath.is_file(): + raise FileNotFoundError("实验记录不存在 / Experiment not found") + try: + data = json.loads(jpath.read_text(encoding="utf-8")) + except Exception: + data = {} + snap = data.get("asset_snapshot_relpath") + jpath.unlink() + thumb = self.experiments_root / f"{clean}.png" + if thumb.is_file(): + thumb.unlink() + if isinstance(snap, str) and snap: + sp = (self.experiments_root / Path(snap).name).resolve() + er = self.experiments_root.resolve() + if str(sp).startswith(str(er)) and sp.is_file(): + sp.unlink() + + def count_experiments_for_input(self, input_name: str) -> int: + """统计引用某素材文件名的实验条数 / Count experiments for an upload basename.""" + base = Path(input_name).name + n = 0 + for r in self._all_experiment_records(): + if (r.get("input_name") or "") == base: + n += 1 + return n + + def delete_experiments_for_input(self, input_name: str) -> int: + """删除所有引用该素材的实验记录 / Cascade-delete experiments by input filename.""" + base = Path(input_name).name + ids = [ + str(r.get("id")) + for r in self._all_experiment_records() + if (r.get("input_name") or "") == base and r.get("id") + ] + for eid in ids: + try: + self.delete_experiment(eid) + except (FileNotFoundError, ValueError): + continue + return len(ids) + + def experiment_asset_path(self, experiment_id: str) -> Path: + """实验素材快照文件路径 / Path to snapshot copy for replay.""" + clean = Path(experiment_id).name + jpath = self.experiments_root / f"{clean}.json" + if not jpath.is_file(): + raise FileNotFoundError("实验记录不存在 / Experiment not found") + data = json.loads(jpath.read_text(encoding="utf-8")) + rel = data.get("asset_snapshot_relpath") + if not rel: + raise FileNotFoundError("无素材快照 / No asset snapshot for this record") + p = (self.experiments_root / Path(str(rel)).name).resolve() + er = self.experiments_root.resolve() + if not str(p).startswith(str(er)) or not p.is_file(): + raise FileNotFoundError("快照文件不存在 / Snapshot missing") + return p + + def _all_experiment_records(self) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for p in sorted( + self.experiments_root.glob("*.json"), + key=lambda x: x.stat().st_mtime, + reverse=True, + ): + try: + data = json.loads(p.read_text(encoding="utf-8")) + if isinstance(data, dict): + rows.append(data) + except Exception: + continue + return rows + + def list_experiments( + self, + q: str | None, + page: int, + page_size: int, + ) -> dict[str, Any]: + """分页列出实验 / Paginated experiment list.""" + rows = self._all_experiment_records() + if q: + ql = q.lower() + rows = [ + r + for r in rows + if ql in (r.get("input_name") or "").lower() + or ql in (r.get("preset_label") or "").lower() + ] + total = len(rows) + start = max(0, (page - 1) * page_size) + end = start + page_size + return { + "total": total, + "page": page, + "page_size": page_size, + "items": rows[start:end], + } + + def export_experiments_json(self) -> str: + """导出全部实验为 JSON 字符串 / Export all as JSON.""" + rows = self._all_experiment_records() + return json.dumps(rows, ensure_ascii=False, indent=2) + + def export_experiments_csv(self) -> str: + """导出 CSV / Export CSV.""" + items = self._all_experiment_records() + buf = io.StringIO() + w = csv.writer(buf) + w.writerow( + [ + "id", + "created_at", + "input_name", + "preset_label", + "matches", + "rmse_arcsec", + ] + ) + for r in items: + m = r.get("metrics") or {} + w.writerow( + [ + r.get("id"), + r.get("created_at"), + r.get("input_name"), + r.get("preset_label"), + m.get("matches", ""), + m.get("rmse_arcsec", ""), + ] + ) + return buf.getvalue() diff --git a/ogscope/web/api/analysis/routes.py b/ogscope/web/api/analysis/routes.py new file mode 100644 index 0000000..0248ed1 --- /dev/null +++ b/ogscope/web/api/analysis/routes.py @@ -0,0 +1,362 @@ +""" +素材分析路由 / Asset analysis routes +""" + +import json +import mimetypes + +from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse, PlainTextResponse + +from ogscope.web.api.analysis.services import analysis_service +from ogscope.web.api.models.schemas import ( + AnalysisBatchSolveRequest, + AnalysisExperimentCreate, + AnalysisExtractPreviewRequest, + AnalysisJobCreateRequest, + AnalysisPresetCreate, + AnalysisReplaceVideoRequest, + AnalysisSolveImageRequest, + AnalysisSolveVideoFrameRequest, + ImportFromDebugRequest, +) + +router = APIRouter() + + +@router.get("/analysis/uploads") +async def list_analysis_uploads(): + """列出已上传素材(持久化目录)/ List persisted uploads""" + try: + return analysis_service.list_uploads() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/uploads/{filename}/experiment_count") +async def upload_experiment_count(filename: str): + """引用该素材的实验记录条数 / Count experiments for upload.""" + try: + return analysis_service.upload_experiment_count(filename) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/analysis/uploads/{filename}") +async def delete_analysis_upload( + filename: str, + delete_experiments: bool = Query( + False, + description="同时删除引用该素材的实验记录 / Also delete linked experiments", + ), +): + """从素材池删除文件及侧车 / Delete file from pool and sidecar.""" + try: + return analysis_service.delete_upload( + filename, delete_experiments=delete_experiments + ) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/uploads/{filename}/info") +async def get_analysis_upload_file_info(filename: str): + """上传素材侧车合并信息(与调试 info 形状对齐)/ Upload file + sidecar merged info.""" + try: + return analysis_service.get_upload_file_info(filename) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/uploads/file") +async def get_analysis_upload_file( + filename: str = Query(..., description="文件名 / Basename") +): + """下载已上传文件(预览或复用)/ Serve persisted upload for preview or reuse""" + try: + path = analysis_service.resolve_upload_path(filename) + if not path.is_file(): + raise HTTPException(status_code=404, detail="文件不存在 / File not found") + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + suffix = path.suffix.lower() + media_map = { + ".mp4": "video/mp4", + ".m4v": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".avi": "video/x-msvideo", + } + media = media_map.get(suffix) + if not media: + media, _ = mimetypes.guess_type(path.name) + return FileResponse( + path, + media_type=media or "application/octet-stream", + filename=path.name, + ) + + +@router.post("/analysis/upload") +async def upload_analysis_asset( + file: UploadFile = File(...), + source: str = Form(default="analysis_upload"), +): + """上传素材 / Upload asset(可选来源标签 / optional source tag)""" + try: + payload = await file.read() + return await analysis_service.save_upload( + filename=file.filename or "uploaded.bin", + payload=payload, + source=source, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/uploads/import_from_debug") +async def import_upload_from_debug(body: ImportFromDebugRequest): + """从调试采集目录复制到素材池 / Copy dev_captures file into analysis pool.""" + try: + return analysis_service.import_from_debug_capture(body.filename) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/uploads/replace_video") +async def replace_transcoded_video(body: AnalysisReplaceVideoRequest): + """客户端转码后替换视频并清理原文件 / Replace uploaded video after client transcode.""" + try: + return analysis_service.replace_transcoded_video(body) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/jobs") +async def create_analysis_job(payload: AnalysisJobCreateRequest): + """创建任务 / Create analysis job""" + try: + return await analysis_service.create_job( + input_name=payload.input_name, + input_type=payload.input_type, + hint_ra_deg=payload.hint_ra_deg, + hint_dec_deg=payload.hint_dec_deg, + frame_step=payload.frame_step, + max_frames=payload.max_frames, + fov_estimate=payload.fov_estimate, + fov_max_error=payload.fov_max_error, + solve_timeout_ms=payload.solve_timeout_ms, + centroid=payload.centroid, + max_image_side=payload.max_image_side, + large_scale_bg_subtract=bool(payload.large_scale_bg_subtract), + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/solve/image") +async def solve_single_image(body: AnalysisSolveImageRequest): + """直接解算单图(JSON body)/ Solve single image via JSON body.""" + try: + return await analysis_service.solve_single_image(body) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/solve/batch") +async def solve_batch(body: AnalysisBatchSolveRequest): + """同一素材多组参数批量解算 / Batch solve with multiple param sets.""" + try: + return await analysis_service.batch_solve(body) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/presets") +async def list_analysis_presets( + scope: str = Query("user", description="official | user") +): + """列出预设 / List presets.""" + try: + return analysis_service.list_presets(scope) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/presets") +async def create_analysis_preset(body: AnalysisPresetCreate): + """创建用户预设 / Create user preset.""" + try: + return analysis_service.create_user_preset(body) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/analysis/presets/{preset_id}") +async def delete_analysis_preset(preset_id: str): + """删除用户预设 / Delete user preset.""" + try: + analysis_service.delete_user_preset(preset_id) + return {"success": True} + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/experiments") +async def create_analysis_experiment(body: AnalysisExperimentCreate): + """保存实验记录 / Save experiment record.""" + try: + return analysis_service.create_experiment(body) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.delete("/analysis/experiments/{experiment_id}") +async def delete_analysis_experiment(experiment_id: str): + """删除一条实验记录 / Delete one experiment record.""" + try: + analysis_service.delete_experiment(experiment_id) + return {"success": True} + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/experiments") +async def list_analysis_experiments( + q: str | None = Query(None, description="搜索文件名或预设名 / Search"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=200), +): + """实验记录列表 / Experiment list.""" + try: + return analysis_service.list_experiments(q, page, page_size) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/experiments/export") +async def export_analysis_experiments( + export_format: str = Query("json", alias="format", description="json | csv"), +): + """导出实验记录 / Export experiments.""" + try: + text = analysis_service.export_experiments(export_format) + media = ( + "application/json" if export_format == "json" else "text/csv; charset=utf-8" + ) + return PlainTextResponse(content=text, media_type=media) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/settings") +async def analysis_lab_settings(): + """分析台公开默认配置 / Public defaults for analysis lab.""" + try: + return analysis_service.lab_public_settings() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/solve/frame") +async def solve_analysis_frame(body: AnalysisSolveVideoFrameRequest): + """相机或视频单帧解算 / Solve one frame from camera or pool video.""" + try: + return await analysis_service.solve_video_frame(body) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/analysis/solve/frame_upload") +async def solve_uploaded_frame( + file: UploadFile = File(...), + payload: str = Form( + ..., + description="JSON 字符串,字段与 AnalysisSolveImageRequest 对齐 / JSON payload aligned with AnalysisSolveImageRequest", + ), +): + """上传单帧 JPEG/PNG 并解算 / Solve a single uploaded frame (multipart).""" + try: + raw = await file.read() + obj = json.loads(payload) + if not isinstance(obj, dict): + raise ValueError("payload 必须为 JSON 对象 / payload must be a JSON object") + topn = obj.get("overlay_topn_count") + enable_polar = obj.get("enable_polar_guide") + solve_interval_ms = obj.get("solve_interval_ms") + obj.pop("overlay_topn_count", None) + obj.pop("enable_polar_guide", None) + obj.pop("solve_interval_ms", None) + # 前端调试元数据,不参与 Pydantic 模型 / Client metadata not in schema + obj.pop("time_sec", None) + obj.pop("frame_width", None) + obj.pop("frame_height", None) + obj.setdefault("input_name", "__frame_upload__.jpg") + data = AnalysisSolveImageRequest.model_validate(obj) + return await analysis_service.solve_uploaded_frame( + image_bytes=raw, + solve_params=data, + overlay_topn_count=topn, + enable_polar_guide=enable_polar, + solve_interval_ms=solve_interval_ms, + ) + except HTTPException: + raise + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/experiments/{experiment_id}/asset") +async def get_experiment_asset_file(experiment_id: str): + """实验素材快照(用于回放)/ Experiment asset snapshot for replay.""" + try: + path = analysis_service.get_experiment_asset_path(experiment_id) + media, _ = mimetypes.guess_type(path.name) + return FileResponse(path, media_type=media or "application/octet-stream") + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.post("/analysis/extract/preview") +async def extract_centroid_preview(body: AnalysisExtractPreviewRequest): + """提星二值掩膜预览(不调解算)/ Preview centroid binary mask without plate solve.""" + try: + return await analysis_service.extract_preview(body) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/analysis/jobs/{job_id}") +async def get_analysis_job(job_id: str): + """查询任务状态 / Query job status""" + try: + return await analysis_service.get_job_status(job_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=404, detail=str(exc)) from exc + + +@router.get("/analysis/jobs/{job_id}/result") +async def get_analysis_result(job_id: str): + """查询任务结果 / Query job result""" + try: + return await analysis_service.get_job_result(job_id) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/ogscope/web/api/analysis/services.py b/ogscope/web/api/analysis/services.py new file mode 100644 index 0000000..e244442 --- /dev/null +++ b/ogscope/web/api/analysis/services.py @@ -0,0 +1,1431 @@ +""" +素材分析服务 / Asset analysis services +""" + +from __future__ import annotations + +import asyncio +import json +import math +import shutil +import time +import uuid +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +from ogscope.algorithms.plate_solve import ( + CentroidExtractionParams, + PlateSolver, + centroid_extraction_preview, + merge_centroid_params, +) +from ogscope.algorithms.star_extract import StarExtractor +from ogscope.config import get_settings +from ogscope.web.api.analysis.lab_store import AnalysisLabStore +from ogscope.web.api.models.schemas import ( + AnalysisBatchSolveRequest, + AnalysisExperimentCreate, + AnalysisExtractPreviewRequest, + AnalysisPresetCreate, + AnalysisReplaceVideoRequest, + AnalysisSolveImageRequest, + AnalysisSolveVideoFrameRequest, + CentroidParamsPayload, +) + +_SOLVE_PROFILE_DEFAULT = "balanced" +_SOLVE_PROFILE_OVERRIDES: dict[str, dict[str, Any]] = { + "speed": { + "timeout_ms": 1000, + "max_stars": 40, + "centroid": { + "sigma": 3.4, + "min_area": 8, + "max_area": 280, + "binary_open": True, + "max_axis_ratio": 2.2, + }, + }, + "balanced": { + "timeout_ms": 1500, + "max_stars": 60, + "centroid": { + "sigma": 3.0, + "min_area": 6, + "max_area": 360, + "binary_open": True, + "max_axis_ratio": 2.8, + }, + }, + "robust": { + "timeout_ms": 3000, + "max_stars": 90, + "centroid": { + "sigma": 2.5, + "min_area": 4, + "max_area": 500, + "binary_open": True, + "max_axis_ratio": None, + }, + }, +} + + +def _merge_debug_style_sidecar_into_info( + info: dict[str, Any], capture_info: dict[str, Any] +) -> None: + """将侧车 JSON 的 camera/extra 展开到顶层,与调试页 info 一致 / Match debug file info shape.""" + cam = capture_info.get("camera") + if isinstance(cam, dict): + for k in ( + "exposure_us", + "analogue_gain", + "digital_gain", + "fps", + "auto_exposure", + "rotation", + "sampling_mode", + "color_mode", + "sensor", + "resolution", + ): + if k not in capture_info and k in cam: + capture_info[k] = cam[k] + if capture_info.get("resolution") is None: + ow = cam.get("output_width") or cam.get("width") + oh = cam.get("output_height") or cam.get("height") + if ow and oh: + capture_info["resolution"] = f"{ow}x{oh}" + extra = capture_info.get("extra") + if isinstance(extra, dict): + for k, v in extra.items(): + if k not in capture_info: + capture_info[k] = v + info.update(capture_info) + + +@dataclass(slots=True) +class AnalysisJob: + """分析任务 / Analysis job""" + + job_id: str + input_name: str + input_type: str + status: str = "queued" + progress: float = 0.0 + message: str = "" + result_path: str | None = None + created_at: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + def to_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "input_name": self.input_name, + "input_type": self.input_type, + "status": self.status, + "progress": self.progress, + "message": self.message, + "result_path": self.result_path, + "created_at": self.created_at, + } + + +@dataclass(slots=True) +class RealtimeSolveGateState: + """实时解算门禁状态 / Gate state for realtime solving.""" + + in_flight: bool = False + last_started_mono: float = 0.0 + last_finished_mono: float = 0.0 + + +class AnalysisService: + """分析服务 / Analysis service""" + + def __init__(self) -> None: + settings = get_settings() + self.upload_root = settings.upload_dir / "analysis" + self.jobs_root = settings.analysis_dir / "jobs" + self.results_root = settings.analysis_dir / "results" + self.upload_root.mkdir(parents=True, exist_ok=True) + self.jobs_root.mkdir(parents=True, exist_ok=True) + self.results_root.mkdir(parents=True, exist_ok=True) + # 解算专用线程池(避免与相机预览等争用默认线程池);默认单 worker 降低 Zero 2W 等低内存设备上并发解算的内存峰值 / Dedicated executor for solving; default 1 worker to reduce peak RAM on low-memory boards + self._solver_executor = ThreadPoolExecutor( + max_workers=1, thread_name_prefix="solver" + ) + self._solver_max_stars = settings.solver_max_stars + self.extractor = StarExtractor(max_stars=settings.solver_max_stars) + self.solver = PlateSolver( + fov_deg=settings.solver_fov_deg, + fov_max_error_deg=settings.solver_fov_max_error_deg, + solve_timeout_ms=settings.solver_timeout_ms, + ) + self.default_hint_ra = settings.solver_hint_ra_deg + self.default_hint_dec = settings.solver_hint_dec_deg + self._jobs: dict[str, AnalysisJob] = {} + self._lab = AnalysisLabStore(settings) + self._overlay_topn_default = 3 + self._polar_guide_default = True + self._realtime_gate_lock = asyncio.Lock() + self._realtime_gate_states: dict[str, RealtimeSolveGateState] = { + "camera": RealtimeSolveGateState(), + "file": RealtimeSolveGateState(), + } + + async def _try_enter_realtime_gate( + self, source: str, interval_ms: int + ) -> dict[str, Any] | None: + """尝试进入实时解算门禁;返回 skip 响应或 None / Try entering gate; return skip response or None.""" + now = time.monotonic() + interval = max(0.0, float(interval_ms) / 1000.0) + async with self._realtime_gate_lock: + state = self._realtime_gate_states.setdefault( + source, RealtimeSolveGateState() + ) + if state.in_flight: + return { + "success": True, + "gate_status": "SKIPPED_BUSY", + "gate_reason": "previous request still running", + "next_allowed_in_ms": 0, + } + if state.last_finished_mono > 0: + elapsed = now - state.last_finished_mono + if elapsed < interval: + wait_ms = max(0, int((interval - elapsed) * 1000.0)) + return { + "success": True, + "gate_status": "SKIPPED_INTERVAL", + "gate_reason": "minimum interval not reached", + "next_allowed_in_ms": wait_ms, + } + state.in_flight = True + state.last_started_mono = now + return None + + async def _leave_realtime_gate(self, source: str) -> None: + """释放实时解算门禁 / Leave realtime solve gate.""" + now = time.monotonic() + async with self._realtime_gate_lock: + state = self._realtime_gate_states.setdefault( + source, RealtimeSolveGateState() + ) + state.in_flight = False + state.last_finished_mono = now + + def is_realtime_source_busy(self, source: str) -> bool: + """判断实时解算源是否繁忙 / Check whether realtime source is busy.""" + state = self._realtime_gate_states.get(source) + return bool(state and state.in_flight) + + def _resolve_realtime_interval_ms( + self, requested_ms: int | None + ) -> tuple[int, int]: + """解析实时解算间隔并按系统上下限裁剪 / Resolve realtime interval with server bounds.""" + if requested_ms is None: + return 0, 0 + settings = get_settings() + min_interval_ms = int(settings.star_analysis_min_interval_ms) + max_interval_ms = int(settings.star_analysis_max_interval_ms) + requested_interval_ms = int(requested_ms) + effective_interval_ms = min( + max(requested_interval_ms, min_interval_ms), max_interval_ms + ) + return requested_interval_ms, effective_interval_ms + + def _build_topn_labels( + self, + row: dict[str, Any], + *, + topn_count: int, + ) -> list[dict[str, Any]]: + """从 matched 星点构建 Top-N 标注 / Build Top-N labels from matched stars.""" + overlay = row.get("solve_overlay") + if not isinstance(overlay, dict): + return [] + matched = overlay.get("stars_matched") + if not isinstance(matched, list): + return [] + labels: list[dict[str, Any]] = [] + for star in matched: + if not isinstance(star, dict): + continue + mag_raw = star.get("mag") + try: + mag_val = float(mag_raw) if mag_raw is not None else None + except (TypeError, ValueError): + mag_val = None + cat_id = star.get("cat_id") + if isinstance(cat_id, list): + cat_id_text = "-".join(str(v) for v in cat_id if v is not None) + elif cat_id is None: + cat_id_text = "" + else: + cat_id_text = str(cat_id) + # 目前基于 Tetra3 匹配 ID 提供可读名占位,后续可接入正式星表映射 + # Build readable placeholder name from Tetra3 cat_id; can be replaced by real catalog lookup later. + name = f"CAT-{cat_id_text}" if cat_id_text else "Unnamed" + item = { + "x": star.get("x"), + "y": star.get("y"), + "name": name, + "mag": mag_val, + "ra_deg": star.get("ra_deg"), + "dec_deg": star.get("dec_deg"), + } + labels.append(item) + labels.sort( + key=lambda x: ( + x["mag"] is None, + float(x["mag"]) if x["mag"] is not None else 999.0, + ) + ) + n = max(1, int(topn_count)) + return labels[:n] + + def _build_polar_guide(self, row: dict[str, Any]) -> dict[str, Any] | None: + """构建极轴引导向量 / Build polar guide vector from solve center.""" + overlay = row.get("solve_overlay") + if not isinstance(overlay, dict): + return None + frame_shape = overlay.get("frame_shape") + if ( + not isinstance(frame_shape, list) + or len(frame_shape) < 2 + or frame_shape[0] in (None, 0) + or frame_shape[1] in (None, 0) + ): + return None + try: + h = float(frame_shape[0]) + w = float(frame_shape[1]) + ra_center = float(row.get("ra_deg")) + dec_center = float(row.get("dec_deg")) + except (TypeError, ValueError): + return None + fov_deg = row.get("fov_deg") + roll_deg = row.get("roll_deg") + if fov_deg is None: + return None + try: + fov = float(fov_deg) + except (TypeError, ValueError): + return None + roll = 0.0 + try: + if roll_deg is not None: + roll = float(roll_deg) + except (TypeError, ValueError): + roll = 0.0 + + # 北天极近似目标:RA 与当前中心相同,Dec=+90,减少 RA wrap 影响 + # Approximate north celestial pole target with same RA and Dec=+90. + target_ra = ra_center + target_dec = 90.0 + d_ra = target_ra - ra_center + while d_ra > 180.0: + d_ra -= 360.0 + while d_ra < -180.0: + d_ra += 360.0 + d_dec = target_dec - dec_center + east_deg = d_ra * math.cos(math.radians(dec_center)) + north_deg = d_dec + roll_rad = math.radians(roll) + x_deg = east_deg * math.cos(roll_rad) + north_deg * math.sin(roll_rad) + y_deg = -east_deg * math.sin(roll_rad) + north_deg * math.cos(roll_rad) + + px_per_deg = (min(w, h) / max(fov, 1e-6)) if fov > 0 else 1.0 + dx_px = x_deg * px_per_deg + dy_px = -y_deg * px_per_deg + cx = w * 0.5 + cy = h * 0.5 + tx = cx + dx_px + ty = cy + dy_px + + c_dec = math.radians(dec_center) + t_dec = math.radians(target_dec) + d_ra_rad = math.radians(d_ra) + cos_ang = math.sin(c_dec) * math.sin(t_dec) + math.cos(c_dec) * math.cos( + t_dec + ) * math.cos(d_ra_rad) + cos_ang = max(-1.0, min(1.0, cos_ang)) + angular_sep_deg = math.degrees(math.acos(cos_ang)) + + return { + "target_kind": "north_celestial_pole", + "frame_center": { + "x": cx, + "y": cy, + "ra_deg": ra_center, + "dec_deg": dec_center, + }, + "target": {"x": tx, "y": ty, "ra_deg": target_ra, "dec_deg": target_dec}, + "delta_px": {"dx": dx_px, "dy": dy_px}, + "angular_sep_deg": angular_sep_deg, + } + + def _centroid_params_from_payload( + self, payload: CentroidParamsPayload | None + ) -> CentroidExtractionParams | None: + """合并请求中的提星覆盖项与默认配置 / Merge API overrides with Settings defaults.""" + if payload is None: + return None + base = CentroidExtractionParams.from_settings(get_settings()) + return merge_centroid_params(base, payload.model_dump(exclude_none=True)) + + def _resolve_solve_profile( + self, + profile_name: str | None, + payload: CentroidParamsPayload | None, + solve_timeout_ms: int | None, + ) -> tuple[CentroidExtractionParams, int, int, str]: + """解析解算分档并返回参数 / Resolve solve profile into concrete params.""" + settings = get_settings() + effective = str(profile_name or _SOLVE_PROFILE_DEFAULT).lower() + if effective not in _SOLVE_PROFILE_OVERRIDES: + effective = _SOLVE_PROFILE_DEFAULT + + profile_cfg = _SOLVE_PROFILE_OVERRIDES[effective] + base = CentroidExtractionParams.from_settings(settings) + centroid = merge_centroid_params(base, profile_cfg.get("centroid", {})) + if payload is not None: + centroid = merge_centroid_params( + centroid, payload.model_dump(exclude_none=True) + ) + + max_stars = int(profile_cfg.get("max_stars", self._solver_max_stars)) + timeout_ms = int( + solve_timeout_ms + if solve_timeout_ms is not None + else profile_cfg.get("timeout_ms", settings.solver_timeout_ms) + ) + return centroid, max(4, max_stars), max(200, timeout_ms), effective + + def resolve_upload_path(self, filename: str) -> Path: + """解析上传目录内安全路径(仅单层文件名)/ Safe path under upload_root (basename only).""" + clean = filename.strip() + name = Path(clean).name + if not name or name != clean: + raise ValueError("文件名无效 / Invalid filename") + path = (self.upload_root / name).resolve() + root = self.upload_root.resolve() + try: + path.relative_to(root) + except ValueError as exc: + raise ValueError("路径非法 / Invalid path") from exc + return path + + def _resolve_frame_source_path(self, input_name: str) -> Path: + """单帧视频解算源路径:优先素材池,其次调试录制目录 / Resolve frame-solve source path.""" + name = Path(input_name.strip()).name + if not name or name != input_name.strip(): + raise ValueError("文件名无效 / Invalid filename") + try: + up = self.resolve_upload_path(name) + if up.is_file(): + return up + except ValueError: + pass + dbg = Path.home() / "dev_captures" / name + if dbg.is_file(): + return dbg + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + + def get_upload_file_info(self, filename: str) -> dict[str, Any]: + """从上传目录读取文件与 stem.txt 侧车 / File + optional sidecar from upload pool.""" + path = self.resolve_upload_path(filename) + if not path.is_file(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + st = path.stat() + image_ext = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp"} + video_ext = { + ".mp4", + ".avi", + ".mov", + ".mkv", + ".wmv", + ".flv", + ".webm", + ".m4v", + } + suffix = path.suffix.lower() + file_type = ( + "image" + if suffix in image_ext + else "video" if suffix in video_ext else "file" + ) + info: dict[str, Any] = { + "filename": path.name, + "size": st.st_size, + "modified": datetime.fromtimestamp( + st.st_mtime, tz=timezone.utc + ).isoformat(), + "type": file_type, + } + sidecar = self.upload_root / f"{path.stem}.txt" + if sidecar.is_file(): + try: + raw = json.loads(sidecar.read_text(encoding="utf-8")) + if isinstance(raw, dict): + _merge_debug_style_sidecar_into_info(info, raw) + except (json.JSONDecodeError, OSError): + pass + return info + + def list_uploads(self) -> dict[str, Any]: + """列出已持久化上传的文件 / List persisted uploads (flat, no recursion).""" + root = self.upload_root + files: list[dict[str, Any]] = [] + if not root.is_dir(): + return {"upload_dir": str(root.resolve()), "files": []} + for p in root.iterdir(): + if not p.is_file(): + continue + if p.name.startswith("."): + continue + if p.name == "manifest.json": + continue + # 侧车 .txt 不单独列入素材池 / Hide sidecar metadata from pool list + if p.suffix.lower() == ".txt": + continue + st = p.stat() + base = { + "filename": p.name, + "size": st.st_size, + "modified_at": datetime.fromtimestamp( + st.st_mtime, tz=timezone.utc + ).isoformat(), + } + files.append(self._lab.merge_list_entry(p.name, base)) + files.sort(key=lambda x: x["modified_at"], reverse=True) + return {"upload_dir": str(root.resolve()), "files": files} + + async def save_upload( + self, filename: str, payload: bytes, source: str = "analysis_upload" + ) -> dict[str, Any]: + """保存上传文件 / Save uploaded file""" + safe_name = Path(filename).name + if not safe_name: + raise ValueError("文件名无效 / Invalid filename") + target = self.upload_root / safe_name + target.write_bytes(payload) + self._lab.set_file_source(safe_name, source) + return { + "success": True, + "filename": safe_name, + "path": str(target), + "size": target.stat().st_size, + } + + async def create_job( + self, + input_name: str, + input_type: str, + hint_ra_deg: float | None = None, + hint_dec_deg: float | None = None, + frame_step: int = 1, + max_frames: int = 180, + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + centroid: CentroidParamsPayload | None = None, + max_image_side: int | None = None, + large_scale_bg_subtract: bool = False, + ) -> dict[str, Any]: + """创建并执行任务 / Create and execute job""" + if input_type not in {"image", "video"}: + raise ValueError( + "input_type 仅支持 image 或 video / input_type must be image or video" + ) + + source = self.upload_root / Path(input_name).name + if not source.exists(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + + job = AnalysisJob( + job_id=str(uuid.uuid4()), input_name=source.name, input_type=input_type + ) + self._jobs[job.job_id] = job + self._persist_job(job) + centroid_params = self._centroid_params_from_payload(centroid) + + try: + job.status = "running" + job.message = "开始分析 / Analysis started" + self._persist_job(job) + loop = asyncio.get_running_loop() + if input_type == "image": + results = await loop.run_in_executor( + self._solver_executor, + self._analyze_image, + source, + hint_ra_deg, + hint_dec_deg, + fov_estimate, + fov_max_error, + solve_timeout_ms, + centroid_params, + max_image_side, + None, + large_scale_bg_subtract, + ) + else: + results = await loop.run_in_executor( + self._solver_executor, + self._analyze_video, + source, + hint_ra_deg, + hint_dec_deg, + frame_step, + max_frames, + job, + fov_estimate, + fov_max_error, + solve_timeout_ms, + ) + result_path = self.results_root / f"{job.job_id}.json" + result_payload = { + "job_id": job.job_id, + "input_name": job.input_name, + "input_type": job.input_type, + "results": results, + } + result_path.write_text( + json.dumps(result_payload, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + job.status = "succeeded" + job.progress = 1.0 + job.message = "分析完成 / Analysis finished" + job.result_path = str(result_path) + self._persist_job(job) + except Exception as exc: # noqa: BLE001 + job.status = "failed" + job.message = f"分析失败 / Analysis failed: {exc}" + self._persist_job(job) + raise + return job.to_dict() + + async def solve_single_image( + self, body: AnalysisSolveImageRequest + ) -> dict[str, Any]: + """直接解算单图(JSON body)/ Solve a single image via JSON body.""" + source = self.upload_root / Path(body.input_name).name + if not source.exists(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + loop = asyncio.get_running_loop() + # 档位与两段策略解析 / Resolve profile and two-stage strategy + centroid_params, max_stars, timeout_ms, requested_profile = ( + self._resolve_solve_profile( + body.solve_profile, body.centroid, body.solve_timeout_ms + ) + ) + + ls_bg = bool(body.large_scale_bg_subtract) + + def _run_single() -> list[dict[str, Any]]: + return self._analyze_image( + source=source, + hint_ra_deg=body.hint_ra_deg, + hint_dec_deg=body.hint_dec_deg, + fov_estimate=body.fov_estimate, + fov_max_error=body.fov_max_error, + solve_timeout_ms=timeout_ms, + centroid_params=centroid_params, + max_image_side=body.max_image_side, + max_stars=max_stars, + large_scale_bg_subtract=ls_bg, + ) + + def _run_two_stage() -> list[dict[str, Any]]: + """平衡档位使用 speed→robust 两段策略 / Balanced profile: speed then robust fallback.""" + # 第 1 段:speed 档快速尝试 / Stage 1: quick speed attempt + speed_centroid, speed_max_stars, speed_timeout_ms, _ = ( + self._resolve_solve_profile( + "speed", body.centroid, body.solve_timeout_ms + ) + ) + first = self._analyze_image( + source=source, + hint_ra_deg=body.hint_ra_deg, + hint_dec_deg=body.hint_dec_deg, + fov_estimate=body.fov_estimate, + fov_max_error=body.fov_max_error, + solve_timeout_ms=speed_timeout_ms, + centroid_params=speed_centroid, + max_image_side=body.max_image_side, + max_stars=speed_max_stars, + large_scale_bg_subtract=ls_bg, + ) + row0 = first[0] if first else None + if row0 and row0.get("status") == "MATCH_FOUND": + row0["solve_profile"] = "speed" + return [row0] + + # 噪点图动态 max_stars 收紧 / Heuristic: tighten max_stars for noisy frames + detected = int(row0.get("detected_stars") or 0) if row0 else 0 + robust_centroid, robust_max_stars, robust_timeout_ms, _ = ( + self._resolve_solve_profile( + "robust", body.centroid, body.solve_timeout_ms + ) + ) + if detected > 0 and detected > robust_max_stars: + robust_max_stars = max(20, int(robust_max_stars * 0.7)) + + second = self._analyze_image( + source=source, + hint_ra_deg=body.hint_ra_deg, + hint_dec_deg=body.hint_dec_deg, + fov_estimate=body.fov_estimate, + fov_max_error=body.fov_max_error, + solve_timeout_ms=robust_timeout_ms, + centroid_params=robust_centroid, + max_image_side=body.max_image_side, + max_stars=robust_max_stars, + large_scale_bg_subtract=ls_bg, + ) + if second: + second[0]["solve_profile"] = "robust" + return second + + # balanced 档默认启用两段策略,其他档位单次解算 / Balanced uses two-stage, others single-pass + if requested_profile == "balanced": + rows = await loop.run_in_executor(self._solver_executor, _run_two_stage) + effective_profile = ( + rows[0].get("solve_profile") + if rows and rows[0].get("solve_profile") + else "balanced" + ) + else: + rows = await loop.run_in_executor(self._solver_executor, _run_single) + effective_profile = requested_profile + + row = rows[0] if rows else None + if row and "solve_profile" not in row: + row["solve_profile"] = effective_profile + # 默认精简 raw,大字段仅在 detail_level==full 时返回 / Drop heavy raw unless client asks for full detail. + detail_level = getattr(body, "detail_level", None) or "summary" + if row and detail_level != "full": + row.pop("tetra", None) + if row: + self._lab.update_last_solve( + source.name, + self._metrics_from_solve_row(row), + ) + return { + "success": True, + "input_name": source.name, + "result": row, + } + + @staticmethod + def _metrics_from_solve_row(row: dict[str, Any]) -> dict[str, Any]: + """提取列表与实验用指标 / Metrics for manifest and experiments.""" + return { + "matches": row.get("matches"), + "rmse_arcsec": row.get("rmse_arcsec"), + "status": row.get("status"), + "prob": row.get("prob"), + "t_solve_ms": row.get("t_solve_ms"), + } + + async def batch_solve(self, body: AnalysisBatchSolveRequest) -> dict[str, Any]: + """多组参数顺序解算同一文件 / Batch solve same file with multiple param sets.""" + results: list[dict[str, Any]] = [] + for run in body.runs: + params = run.params.model_dump(exclude_none=True) + req = AnalysisSolveImageRequest.model_validate( + {"input_name": body.input_name, **params} + ) + try: + out = await self.solve_single_image(req) + results.append( + { + "label": run.label, + "success": True, + "result": out.get("result"), + "input_name": out.get("input_name"), + } + ) + except Exception as exc: # noqa: BLE001 + results.append( + { + "label": run.label, + "success": False, + "error": str(exc), + } + ) + return {"input_name": body.input_name, "results": results} + + def import_from_debug_capture(self, filename: str) -> dict[str, Any]: + """从 ~/dev_captures 复制到分析素材池并标记来源 / Copy debug capture into pool.""" + src = Path.home() / "dev_captures" / Path(filename).name + if not src.is_file(): + raise FileNotFoundError( + "调试采集文件不存在 / Debug capture file not found in dev_captures" + ) + dst = self.upload_root / src.name + shutil.copy2(src, dst) + side_txt = Path.home() / "dev_captures" / f"{src.stem}.txt" + if side_txt.is_file(): + shutil.copy2(side_txt, self.upload_root / side_txt.name) + self._lab.set_file_source(dst.name, "debug_console") + return { + "success": True, + "filename": dst.name, + "size": dst.stat().st_size, + } + + def replace_transcoded_video( + self, body: AnalysisReplaceVideoRequest + ) -> dict[str, Any]: + """转码后替换素材视频并同步侧车 / Replace original video with transcoded output.""" + old_name = Path(body.old_filename.strip()).name + new_name = Path(body.new_filename.strip()).name + if not old_name or not new_name: + raise ValueError("文件名无效 / Invalid filename") + if old_name == new_name: + raise ValueError( + "新旧文件名不能相同 / old_filename and new_filename must differ" + ) + old_path = self.resolve_upload_path(old_name) + new_path = self.resolve_upload_path(new_name) + if not old_path.is_file(): + raise FileNotFoundError("原始文件不存在 / Original file not found") + if not new_path.is_file(): + raise FileNotFoundError("转码文件不存在 / Transcoded file not found") + if old_path.suffix.lower() != ".avi": + raise ValueError( + "仅支持替换 AVI 原始文件 / only AVI source can be replaced" + ) + if new_path.suffix.lower() != ".mp4": + raise ValueError("新文件必须为 MP4 / new file must be MP4") + + old_sidecar = self.upload_root / f"{old_path.stem}.txt" + new_sidecar = self.upload_root / f"{new_path.stem}.txt" + sidecar_payload: dict[str, Any] = {} + if old_sidecar.is_file(): + try: + raw = json.loads(old_sidecar.read_text(encoding="utf-8")) + if isinstance(raw, dict): + sidecar_payload = raw + except (json.JSONDecodeError, OSError): + sidecar_payload = {} + sidecar_payload.setdefault("kind", "video") + sidecar_payload["media_file"] = new_name + sidecar_payload["size"] = int(new_path.stat().st_size) + extra = sidecar_payload.get("extra") + if not isinstance(extra, dict): + extra = {} + extra["codec_fourcc"] = str(body.codec_fourcc or "mp4v") + extra["container"] = str(body.container or "MP4") + if body.duration_s is not None: + extra["duration_s"] = float(body.duration_s) + if body.nominal_fps is not None: + extra["nominal_fps"] = float(body.nominal_fps) + sidecar_payload["extra"] = extra + new_sidecar.write_text( + json.dumps(sidecar_payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + old_path.unlink() + if old_sidecar.is_file() and old_sidecar != new_sidecar: + old_sidecar.unlink() + + manifest = self._lab.load_manifest() + entries = manifest.setdefault("entries", {}) + old_ent = entries.get(old_name, {}) + new_ent = entries.setdefault(new_name, {}) + if isinstance(old_ent, dict): + for key in ("source", "last_solve"): + if key in old_ent and key not in new_ent: + new_ent[key] = old_ent[key] + new_ent["source"] = new_ent.get("source", "analysis_upload") + new_ent["updated_at"] = datetime.now(timezone.utc).isoformat() + if old_name in entries: + del entries[old_name] + self._lab.save_manifest(manifest) + return { + "success": True, + "old_filename": old_name, + "filename": new_name, + "deleted_old": True, + "sidecar": new_sidecar.name, + "size": int(new_path.stat().st_size), + } + + def list_presets(self, scope: str) -> dict[str, Any]: + """列出官方或用户预设 / List official or user presets.""" + if scope not in {"official", "user"}: + raise ValueError( + "scope 须为 official 或 user / scope must be official or user" + ) + return {"scope": scope, "presets": self._lab.list_presets(scope)} + + def create_user_preset(self, body: AnalysisPresetCreate) -> dict[str, Any]: + """创建用户预设 / Create user preset.""" + params = body.params.model_dump(exclude_none=True) + return self._lab.save_user_preset(body.name, params) + + def delete_user_preset(self, preset_id: str) -> None: + """删除用户预设 / Delete user preset.""" + self._lab.delete_user_preset(preset_id) + + def create_experiment(self, body: AnalysisExperimentCreate) -> dict[str, Any]: + """保存实验记录 / Save experiment record.""" + return self._lab.create_experiment( + input_name=body.input_name, + preset_label=body.preset_label, + result_json=body.result_json, + metrics=body.metrics, + thumbnail_png_base64=body.thumbnail_png_base64, + replay=body.replay, + save_asset_snapshot=body.save_asset_snapshot, + ) + + def list_experiments( + self, q: str | None, page: int, page_size: int + ) -> dict[str, Any]: + """分页实验列表 / Paginated experiments.""" + return self._lab.list_experiments(q, page, page_size) + + def delete_upload( + self, filename: str, delete_experiments: bool = False + ) -> dict[str, Any]: + """删除素材池文件及 stem.txt 侧车;可选级联实验记录 / Delete pool file and sidecar; optional cascade.""" + path = self.resolve_upload_path(filename) + if path.name == "manifest.json": + raise ValueError("不可删除清单文件 / Cannot delete manifest") + if not path.is_file(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + n_exp = 0 + if delete_experiments: + n_exp = self._lab.delete_experiments_for_input(path.name) + path.unlink() + side = self.upload_root / f"{path.stem}.txt" + if side.is_file(): + side.unlink() + self._lab.remove_manifest_entry(path.name) + return {"success": True, "filename": path.name, "deleted_experiments": n_exp} + + async def solve_uploaded_frame( + self, + *, + image_bytes: bytes, + solve_params: AnalysisSolveImageRequest, + overlay_topn_count: int | None = None, + enable_polar_guide: bool | None = None, + solve_interval_ms: int | None = None, + ) -> dict[str, Any]: + """解析上传的单帧图像并解算 / Solve a single uploaded frame (multipart).""" + settings = get_settings() + requested_interval_ms, effective_interval_ms = ( + self._resolve_realtime_interval_ms(solve_interval_ms) + ) + gate_skip = await self._try_enter_realtime_gate( + "file_upload", effective_interval_ms + ) + if gate_skip is not None: + gate_skip["requested_interval_ms"] = requested_interval_ms + gate_skip["effective_interval_ms"] = effective_interval_ms + return gate_skip + t_total = time.perf_counter() + try: + if not image_bytes: + raise ValueError("空图像数据 / Empty image payload") + buf = np.frombuffer(image_bytes, dtype=np.uint8) + frame = cv2.imdecode(buf, cv2.IMREAD_COLOR) + if frame is None: + raise ValueError("无法解码图像 / Cannot decode image") + centroid_params, max_stars, timeout_ms, effective_profile = ( + self._resolve_solve_profile( + solve_params.solve_profile, + solve_params.centroid, + solve_params.solve_timeout_ms, + ) + ) + loop = asyncio.get_running_loop() + + def _run() -> dict[str, Any]: + return self._solve_bgr_to_row( + frame, + solve_params.hint_ra_deg, + solve_params.hint_dec_deg, + solve_params.fov_estimate, + solve_params.fov_max_error, + timeout_ms, + centroid_params, + solve_params.max_image_side, + max_stars, + bool(solve_params.large_scale_bg_subtract), + ) + + hard_timeout_sec = max( + 0.2, float(settings.star_analysis_request_timeout_ms) / 1000.0 + ) + row = await asyncio.wait_for( + loop.run_in_executor(self._solver_executor, _run), + timeout=hard_timeout_sec, + ) + # 统一 overlay_ext 结构,便于前端复用渲染逻辑 + topn = ( + int(overlay_topn_count) + if overlay_topn_count is not None + else self._overlay_topn_default + ) + enable_polar = ( + bool(enable_polar_guide) + if enable_polar_guide is not None + else self._polar_guide_default + ) + overlay_ext: dict[str, Any] = {} + try: + overlay_ext["labels_topn"] = self._build_topn_labels( + row, topn_count=topn + ) + except Exception: + overlay_ext["labels_topn"] = [] + if enable_polar: + try: + overlay_ext["polar_guide"] = self._build_polar_guide(row) + except Exception: + overlay_ext["polar_guide"] = None + row["overlay_ext"] = overlay_ext + row["solve_profile"] = effective_profile + row["t_backend_total_ms"] = round( + (time.perf_counter() - t_total) * 1000.0, 3 + ) + detail_level = getattr(solve_params, "detail_level", None) or "summary" + if detail_level != "full": + row.pop("tetra", None) + return { + "success": True, + "result": row, + "gate_status": "SOLVED", + "requested_interval_ms": requested_interval_ms, + "effective_interval_ms": effective_interval_ms, + } + except asyncio.TimeoutError: + return { + "success": True, + "result": { + "status": "TIMEOUT_RELEASED", + "status_code": None, + "solve_source": "full", + "ra_deg": 0.0, + "dec_deg": 0.0, + "detected_stars": 0, + }, + "gate_status": "TIMEOUT_RELEASED", + "gate_reason": "outer request timeout", + "requested_interval_ms": requested_interval_ms, + "effective_interval_ms": effective_interval_ms, + } + finally: + await self._leave_realtime_gate("file_upload") + + def delete_experiment(self, experiment_id: str) -> None: + """删除一条实验记录 / Delete one experiment record.""" + self._lab.delete_experiment(experiment_id) + + def export_experiments(self, fmt: str) -> str: + """导出实验记录 / Export experiments.""" + if fmt == "json": + return self._lab.export_experiments_json() + if fmt == "csv": + return self._lab.export_experiments_csv() + raise ValueError("format 须为 json 或 csv / format must be json or csv") + + async def extract_preview( + self, body: AnalysisExtractPreviewRequest + ) -> dict[str, Any]: + """提星二值掩膜预览(不调 Tetra3 解算)/ Preview binary mask without plate solve.""" + source = self.upload_root / Path(body.input_name).name + if not source.exists(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + centroid_params = self._centroid_params_from_payload(body.centroid) + settings = get_settings() + max_side = ( + body.max_image_side + if body.max_image_side is not None + else settings.solver_max_image_side + ) + if centroid_params is None: + centroid_params = CentroidExtractionParams.from_settings(settings) + + def _run() -> dict[str, Any]: + frame = cv2.imread(str(source), cv2.IMREAD_COLOR) + if frame is None: + raise ValueError("无法读取图片 / Unable to read image") + return centroid_extraction_preview( + frame, + max_stars=self._solver_max_stars, + centroid_params=centroid_params, + max_image_side=int(max_side), + large_scale_bg_subtract=bool(body.large_scale_bg_subtract), + downsample_max_side=int(settings.solver_large_scale_bg_downsample), + ) + + return await asyncio.to_thread(_run) + + async def get_job_status(self, job_id: str) -> dict[str, Any]: + """获取任务状态 / Get job status""" + job = self._jobs.get(job_id) + if job: + return job.to_dict() + + job_file = self.jobs_root / f"{job_id}.json" + if not job_file.exists(): + raise FileNotFoundError("任务不存在 / Job not found") + return json.loads(job_file.read_text(encoding="utf-8")) + + async def get_job_result(self, job_id: str) -> dict[str, Any]: + """获取任务结果 / Get job result""" + status = await self.get_job_status(job_id) + result_path = status.get("result_path") + if not result_path: + raise FileNotFoundError("任务结果未生成 / Result not generated") + rp = Path(result_path) + if not rp.exists(): + raise FileNotFoundError("结果文件不存在 / Result file not found") + return json.loads(rp.read_text(encoding="utf-8")) + + def _persist_job(self, job: AnalysisJob) -> None: + """持久化任务 / Persist job""" + target = self.jobs_root / f"{job.job_id}.json" + target.write_text( + json.dumps(job.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8" + ) + + def _solve_bgr_to_row( + self, + frame_bgr: Any, + hint_ra_deg: float | None, + hint_dec_deg: float | None, + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + centroid_params: CentroidExtractionParams | None = None, + max_image_side: int | None = None, + max_stars: int | None = None, + large_scale_bg_subtract: bool = False, + ) -> dict[str, Any]: + """BGR 帧送 Tetra3 解算 / Plate-solve one BGR frame.""" + solved = self.solver.solve_from_bgr_frame( + frame_bgr=frame_bgr, + max_stars=int( + max_stars if max_stars is not None else self._solver_max_stars + ), + hint_ra_deg=( + hint_ra_deg if hint_ra_deg is not None else self.default_hint_ra + ), + hint_dec_deg=( + hint_dec_deg if hint_dec_deg is not None else self.default_hint_dec + ), + solve_source="full", + fov_estimate=fov_estimate, + fov_max_error=fov_max_error, + solve_timeout_ms=solve_timeout_ms, + centroid_params=centroid_params, + max_image_side=max_image_side, + large_scale_bg_subtract=large_scale_bg_subtract, + ) + return {"frame_index": 0, **solved.to_dict()} + + def _analyze_image( + self, + source: Path, + hint_ra_deg: float | None, + hint_dec_deg: float | None, + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + centroid_params: CentroidExtractionParams | None = None, + max_image_side: int | None = None, + max_stars: int | None = None, + large_scale_bg_subtract: bool = False, + ) -> list[dict[str, Any]]: + """分析单图 / Analyze image""" + t_total = time.perf_counter() + t_decode = time.perf_counter() + frame = cv2.imread(str(source), cv2.IMREAD_COLOR) + t_open_decode_ms = (time.perf_counter() - t_decode) * 1000.0 + if frame is None: + raise ValueError("无法读取图片 / Unable to read image") + row = self._solve_bgr_to_row( + frame, + hint_ra_deg, + hint_dec_deg, + fov_estimate=fov_estimate, + fov_max_error=fov_max_error, + solve_timeout_ms=solve_timeout_ms, + centroid_params=centroid_params, + max_image_side=max_image_side, + max_stars=max_stars, + large_scale_bg_subtract=large_scale_bg_subtract, + ) + row["t_open_decode_ms"] = round(t_open_decode_ms, 3) + row["t_backend_total_ms"] = round((time.perf_counter() - t_total) * 1000.0, 3) + return [row] + + def _analyze_video( + self, + source: Path, + hint_ra_deg: float | None, + hint_dec_deg: float | None, + frame_step: int, + max_frames: int, + job: AnalysisJob, + fov_estimate: float | None = None, + fov_max_error: float | None = None, + solve_timeout_ms: int | None = None, + ) -> list[dict[str, Any]]: + """分析视频 / Analyze video""" + cap = cv2.VideoCapture(str(source)) + if not cap.isOpened(): + raise ValueError("无法打开视频 / Unable to open video") + hint_ra = hint_ra_deg if hint_ra_deg is not None else self.default_hint_ra + hint_dec = hint_dec_deg if hint_dec_deg is not None else self.default_hint_dec + results: list[dict[str, Any]] = [] + idx = -1 + processed = 0 + full_limit = max(1, max_frames) + step = max(1, frame_step) + + while processed < full_limit: + ok, frame = cap.read() + if not ok: + break + idx += 1 + if idx % step != 0: + continue + stars = self.extractor.extract(frame) + solved = self.solver.solve( + stars=stars, + frame_shape=frame.shape, + hint_ra_deg=hint_ra, + hint_dec_deg=hint_dec, + solve_source="full", + fov_estimate=fov_estimate, + fov_max_error=fov_max_error, + solve_timeout_ms=solve_timeout_ms, + ) + hint_ra = solved.ra_deg + hint_dec = solved.dec_deg + results.append({"frame_index": idx, **solved.to_dict()}) + processed += 1 + job.progress = min(0.99, processed / full_limit) + self._persist_job(job) + + cap.release() + return results + + async def solve_video_frame( + self, body: AnalysisSolveVideoFrameRequest + ) -> dict[str, Any]: + """相机或视频文件单帧解算 / Single-frame solve from camera or video file.""" + if body.source == "camera": + try: + from ogscope.web.api.debug.services import is_recording_active + + if is_recording_active(): + return { + "success": True, + "input_name": body.input_name or "", + "gate_status": "SKIPPED_BUSY", + "gate_reason": "recording is active", + "next_allowed_in_ms": 0, + } + except Exception: + pass + settings = get_settings() + requested_interval_ms, effective_interval_ms = ( + self._resolve_realtime_interval_ms(body.solve_interval_ms) + ) + gate_source_key = ( + body.source + if body.source == "camera" + else f"file:{(body.input_name or '').strip()}" + ) + gate_skip = await self._try_enter_realtime_gate( + gate_source_key, effective_interval_ms + ) + if gate_skip is not None: + gate_skip["requested_interval_ms"] = requested_interval_ms + gate_skip["effective_interval_ms"] = effective_interval_ms + gate_skip["input_name"] = body.input_name or "" + return gate_skip + + t_total = time.perf_counter() + t_open_decode_ms = None + frame = None + frame_id = None + frame_ts = None + elapsed_ms = 0.0 + try: + if body.source == "camera": + from ogscope.web.camera_shared import get_camera_manager + + t_decode = time.perf_counter() + frame, frame_id, frame_ts = await get_camera_manager().get_raw_frame() + t_open_decode_ms = (time.perf_counter() - t_decode) * 1000.0 + else: + if not body.input_name: + raise ValueError( + "需要 input_name / input_name required for file source" + ) + path = self._resolve_frame_source_path(body.input_name) + t_decode = time.perf_counter() + cap = cv2.VideoCapture(str(path)) + if not cap.isOpened(): + raise ValueError("无法打开视频 / Cannot open video") + try: + if body.time_sec is not None: + cap.set(cv2.CAP_PROP_POS_MSEC, float(body.time_sec) * 1000.0) + else: + cap.set(cv2.CAP_PROP_POS_FRAMES, float(body.frame_index)) + ok, frame = cap.read() + if not ok or frame is None: + raise ValueError("无法读取视频帧 / Cannot read video frame") + finally: + cap.release() + t_open_decode_ms = (time.perf_counter() - t_decode) * 1000.0 + centroid_params, max_stars, timeout_ms, effective_profile = ( + self._resolve_solve_profile( + body.solve_profile, body.centroid, body.solve_timeout_ms + ) + ) + loop = asyncio.get_running_loop() + + def _run() -> dict[str, Any]: + return self._solve_bgr_to_row( + frame, + body.hint_ra_deg, + body.hint_dec_deg, + body.fov_estimate, + body.fov_max_error, + timeout_ms, + centroid_params, + body.max_image_side, + max_stars, + bool(body.large_scale_bg_subtract), + ) + + hard_timeout_sec = max( + 0.2, float(settings.star_analysis_request_timeout_ms) / 1000.0 + ) + row = await asyncio.wait_for( + loop.run_in_executor(self._solver_executor, _run), + timeout=hard_timeout_sec, + ) + # 二次分析与极轴引导(失败降级,不影响基础解算) + topn = ( + int(body.overlay_topn_count) + if getattr(body, "overlay_topn_count", None) is not None + else self._overlay_topn_default + ) + enable_polar = ( + bool(body.enable_polar_guide) + if getattr(body, "enable_polar_guide", None) is not None + else self._polar_guide_default + ) + overlay_ext: dict[str, Any] = {} + try: + overlay_ext["labels_topn"] = self._build_topn_labels( + row, topn_count=topn + ) + except Exception: + overlay_ext["labels_topn"] = [] + if enable_polar: + try: + overlay_ext["polar_guide"] = self._build_polar_guide(row) + except Exception: + overlay_ext["polar_guide"] = None + row["overlay_ext"] = overlay_ext + if t_open_decode_ms is not None: + row["t_open_decode_ms"] = round(t_open_decode_ms, 3) + elapsed_ms = (time.perf_counter() - t_total) * 1000.0 + row["t_backend_total_ms"] = round(elapsed_ms, 3) + row["solve_profile"] = effective_profile + # 默认精简 raw,大字段仅在 detail_level==full 时返回 / Drop heavy raw unless client asks for full detail. + detail_level = getattr(body, "detail_level", None) or "summary" + if detail_level != "full": + row.pop("tetra", None) + return { + "success": True, + "input_name": body.input_name or "", + "result": row, + "frame_id": frame_id, + "frame_ts": frame_ts, + "gate_status": "SOLVED", + "gate_reason": ( + "slow request" + if elapsed_ms >= float(settings.star_analysis_slow_threshold_ms) + else None + ), + "requested_interval_ms": requested_interval_ms, + "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": effective_interval_ms, + } + except asyncio.TimeoutError: + return { + "success": True, + "input_name": body.input_name or "", + "result": { + "status": "TIMEOUT_RELEASED", + "status_code": None, + "solve_source": "full", + "ra_deg": 0.0, + "dec_deg": 0.0, + "detected_stars": 0, + }, + "frame_id": frame_id, + "frame_ts": frame_ts, + "gate_status": "TIMEOUT_RELEASED", + "gate_reason": "outer request timeout", + "requested_interval_ms": requested_interval_ms, + "effective_interval_ms": effective_interval_ms, + "next_allowed_in_ms": effective_interval_ms, + } + finally: + await self._leave_realtime_gate(gate_source_key) + + def lab_public_settings(self) -> dict[str, Any]: + """分析台默认参数(供前端)/ Public defaults for analysis UI.""" + s = get_settings() + return { + "solver_timeout_ms": s.solver_timeout_ms, + "star_analysis_target_fps": s.star_analysis_target_fps, + "star_analysis_min_interval_ms": s.star_analysis_min_interval_ms, + "star_analysis_max_interval_ms": s.star_analysis_max_interval_ms, + "star_analysis_request_timeout_ms": s.star_analysis_request_timeout_ms, + "star_analysis_slow_threshold_ms": s.star_analysis_slow_threshold_ms, + "camera_width": s.camera_width, + "camera_height": s.camera_height, + "camera_fps": s.camera_fps, + "solver_fov_deg": s.solver_fov_deg, + "solver_max_image_side": s.solver_max_image_side, + "solver_large_scale_bg_downsample": s.solver_large_scale_bg_downsample, + "solve_profile_default": _SOLVE_PROFILE_DEFAULT, + "solve_profiles": list(_SOLVE_PROFILE_OVERRIDES.keys()), + } + + def upload_experiment_count(self, filename: str) -> dict[str, Any]: + """引用该素材的实验条数 / Number of experiments referencing upload.""" + return {"count": self._lab.count_experiments_for_input(filename)} + + def get_experiment_asset_path(self, experiment_id: str) -> Path: + """实验快照路径 / Snapshot path for replay.""" + return self._lab.experiment_asset_path(experiment_id) + + +analysis_service = AnalysisService() diff --git a/ogscope/web/api/camera/routes.py b/ogscope/web/api/camera/routes.py index 74cbf3d..e1dad22 100644 --- a/ogscope/web/api/camera/routes.py +++ b/ogscope/web/api/camera/routes.py @@ -1,83 +1,104 @@ """ -相机相关API路由 +相机相关API路由 / Camera-related API routes +支持真实相机和模拟模式 / Supports real camera and simulation mode """ -from fastapi import APIRouter, HTTPException -from fastapi.responses import StreamingResponse -from ogscope.web.api.models.schemas import CameraSettings -router = APIRouter() +import io +import logging +from fastapi import APIRouter, HTTPException, Query +from fastapi.responses import StreamingResponse -@router.get("/camera/status") -async def get_camera_status(): - """获取相机状态""" - # TODO: 实现相机状态获取 - return { - "connected": False, - "streaming": False, - "resolution": [1920, 1080], - "fps": 30, - } +from ogscope.utils.environment import get_simulation_config, should_use_simulation_mode +from ogscope.utils.virtual_stream import get_virtual_stream +logger = logging.getLogger(__name__) +router = APIRouter() -@router.post("/camera/settings") -async def update_camera_settings(settings: CameraSettings): - """更新相机设置""" - # TODO: 实现相机设置更新 - return { - "success": True, - "settings": settings.dict(), - } +_is_streaming = False +_simulation_mode = should_use_simulation_mode() +if _simulation_mode: + logger.info("检测到非树莓派环境,启用模拟模式") + _virtual_stream = get_virtual_stream() +else: + logger.info("检测到树莓派环境,使用真实相机(与调试/分析共用单例)") -@router.get("/camera/config") -async def get_camera_config(): - """获取相机配置""" - from ogscope.config import get_settings - settings = get_settings() - return { - "type": settings.camera_type, - "width": settings.camera_width, - "height": settings.camera_height, - "fps": settings.camera_fps, - "exposure_us": settings.camera_exposure, - "gain": settings.camera_gain, - } +@router.get("/camera/status") +async def get_camera_status(): + """获取相机状态 / Get camera status""" + if _simulation_mode: + return { + "connected": True, + "streaming": _is_streaming, + "resolution": [1920, 1080], + "fps": 30, + "mode": "simulation", + "simulation_config": get_simulation_config(), + } + else: + try: + from ogscope.web.camera_shared import get_camera_manager -@router.post("/camera/config") -async def update_camera_config(config: dict): - """更新相机配置""" - # TODO: 实现相机配置更新逻辑 - return {"status": "success", "config": config} + status = await get_camera_manager().status() + info = status.get("info", {}) if isinstance(status, dict) else {} + width = int(info.get("output_width") or info.get("width") or 1920) + height = int(info.get("output_height") or info.get("height") or 1080) + fps = int(info.get("fps") or 30) + except Exception as e: + logger.error(f"读取相机状态失败: {e}") + status = {"connected": False, "streaming": False} + width, height, fps = 1920, 1080, 30 + return { + "connected": bool(status.get("connected")), + "streaming": bool(status.get("streaming")), + "resolution": [int(width), int(height)], + "fps": int(fps), + "mode": "real", + "runtime_overrides": status.get("runtime_overrides", {}), + } -@router.post("/camera/start") -async def start_camera(): - """开始相机预览""" - # TODO: 实现相机启动逻辑 - return {"status": "success", "message": "相机预览已启动"} +@router.get("/camera/preview") +async def get_camera_preview(since_frame_id: int | None = Query(default=None)): + """获取相机预览图(JPEG) / Get camera preview (JPEG)""" + if _simulation_mode: + if not _is_streaming: + # 返回静态占位符图像 / Return static placeholder image + placeholder_image = io.BytesIO() + placeholder_image.write( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x90\x00\x00\x00\xf0\x08\x02\x00\x00\x00" + ) + placeholder_image.seek(0) -@router.post("/camera/stop") -async def stop_camera(): - """停止相机预览""" - # TODO: 实现相机停止逻辑 - return {"status": "success", "message": "相机预览已停止"} + return StreamingResponse( + placeholder_image, + media_type="image/png", + headers={"Cache-Control": "no-cache"}, + ) + # 生成虚拟视频帧 / Generate virtual video frames + try: + frame_data = _virtual_stream.generate_frame() + return StreamingResponse( + io.BytesIO(frame_data), + media_type="image/jpeg", + headers={"Cache-Control": "no-cache"}, + ) + except Exception as e: + logger.error(f"生成虚拟视频帧失败: {e}") + raise HTTPException(status_code=500, detail="生成视频帧失败") + else: + try: + # 与调试台共用帧总线;通过 since_frame_id 减少重复 JPEG 下发。 + # Shared frame bus with debug console; use since_frame_id to avoid duplicate payload. + from ogscope.web.api.debug.services import DebugCameraService -@router.get("/camera/preview") -async def get_camera_preview(): - """获取相机预览图(JPEG)""" - # TODO: 实现预览图获取 - # 暂时返回占位符图像 - import io - placeholder_image = io.BytesIO() - placeholder_image.write(b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x01\x90\x00\x00\x00\xf0\x08\x02\x00\x00\x00') - placeholder_image.seek(0) - - return StreamingResponse( - placeholder_image, - media_type="image/png", - headers={"Cache-Control": "no-cache"} - ) + return await DebugCameraService.get_preview(since_frame_id=since_frame_id) + except HTTPException: + raise + except Exception as e: + logger.error(f"获取真实相机预览失败: {e}") + raise HTTPException(status_code=500, detail="获取预览失败") diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index 3be5998..03079cc 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -1,33 +1,145 @@ """ 调试控制台API路由 """ -from fastapi import APIRouter, HTTPException, Query -from fastapi.responses import FileResponse -from fastapi.responses import StreamingResponse -from ogscope.web.api.models.schemas import CameraSettings, CameraPreset + +import asyncio +import datetime as dt +import json +import os +import subprocess +import time + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import FileResponse, Response, StreamingResponse + +from ogscope.core.realtime import realtime_solve_service from ogscope.web.api.debug.services import ( - DebugCameraService, - DebugPresetService, - DebugFileService + DebugCameraService, + DebugFileService, + DebugPresetService, ) +from ogscope.web.api.models.schemas import CameraPreset, CameraSettings router = APIRouter() +_DEFAULT_PREVIEW_JPEG_QUALITY = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) +_PREVIEW_CLIENT_LAST_TS: dict[str, float] = {} +_DEBUG_PREVIEW_MIN_INTERVAL_SEC = max( + 0.0, + float(os.getenv("OGSCOPE_DEBUG_PREVIEW_MIN_INTERVAL_MS", "150") or "150") / 1000.0, +) + + +def _journal_priority_to_level(priority: str | int | None) -> str: + try: + p = int(priority) if priority is not None else 6 + except (TypeError, ValueError): + p = 6 + if p <= 3: + return "ERROR" + if p == 4: + return "WARN" + return "INFO" + + +def _read_journal_logs( + service: str, + since_seconds: int, + limit: int, +) -> list[dict[str, str | int | None]]: + cmd = [ + "journalctl", + "--no-pager", + "-o", + "json", + "-u", + service, + "--since", + f"{since_seconds} seconds ago", + "-n", + str(limit), + ] + proc = subprocess.run(cmd, capture_output=True, text=True, check=False) + if proc.returncode != 0: + raise RuntimeError(proc.stderr.strip() or "journalctl failed") + rows: list[dict[str, str | int | None]] = [] + for line in proc.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + msg = str(item.get("MESSAGE", "")).strip() + if not msg: + continue + priority = item.get("PRIORITY") + level = _journal_priority_to_level(priority) + ts_iso: str | None = None + rt = item.get("__REALTIME_TIMESTAMP") + try: + if rt is not None: + ts = dt.datetime.fromtimestamp( + int(str(rt)) / 1_000_000, tz=dt.timezone.utc + ) + ts_iso = ts.isoformat() + except (ValueError, TypeError): + ts_iso = None + rows.append( + { + "ts": ts_iso, + "level": level, + "message": msg, + "source": str(item.get("_SYSTEMD_UNIT", service)), + "priority": int(priority) if str(priority).isdigit() else None, + } + ) + return rows + + +# ==================== 相机控制 ==================== / ==================== Camera Control ==================== -# ==================== 相机控制 ==================== @router.get("/debug/camera/status") async def get_debug_camera_status(): - """获取调试相机状态""" + """获取调试相机状态 / Get debug camera status""" try: return await DebugCameraService.get_camera_status() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@router.get("/debug/camera/runtime-overrides") +async def get_debug_camera_runtime_overrides(): + """获取运行时预览参数覆盖 / Get runtime preview overrides""" + try: + return await DebugCameraService.get_runtime_overrides() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/runtime-overrides/reset") +async def reset_debug_camera_runtime_overrides(): + """重置运行时预览参数覆盖 / Reset runtime preview overrides""" + try: + return await DebugCameraService.clear_runtime_overrides() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/runtime-overrides/apply-defaults") +async def apply_debug_camera_runtime_overrides_as_defaults(): + """确认将运行时预览参数写为系统默认 / Apply runtime overrides as system defaults""" + try: + return await DebugCameraService.apply_runtime_overrides_as_defaults() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/debug/camera/start") async def start_debug_camera(): - """启动调试相机""" + """启动调试相机 / Start the debug camera""" try: return await DebugCameraService.start_camera() except Exception as e: @@ -35,35 +147,49 @@ async def start_debug_camera(): @router.get("/debug/camera/stream") -async def stream_debug_camera(quality: int = Query(70, ge=10, le=100)): - """MJPEG 实时流 - 可配置压缩质量""" +async def stream_debug_camera( + quality: int = Query(_DEFAULT_PREVIEW_JPEG_QUALITY, ge=10, le=100), +): + """MJPEG 实时流 - 可配置压缩质量 / MJPEG live streaming - configurable compression quality""" try: - from ogscope.web.api.debug.services import DebugCameraService - camera = DebugCameraService.get_camera_instance() - if not camera or not camera.is_capturing: - raise HTTPException(status_code=503, detail="相机未运行") - - import cv2 - import numpy as np - boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): + last_snap_frame_id = -1 + last_emit_mono = 0.0 while True: - frame = camera.get_video_frame() - if frame is None: - break - ok, buf = cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) - if not ok: + code, data, snap_id = await DebugCameraService.get_stream_frame_bytes( + "jpeg", quality, since_frame_id=last_snap_frame_id + ) + if code == 304: + await asyncio.sleep(0.03) + continue + if code != 200 or data is None: + await asyncio.sleep(0.05) continue - data = buf.tobytes() + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) + last_snap_frame_id = snap_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/jpeg\r\n" - b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" + data + b"\r\n" + b"Content-Length: " + + str(len(data)).encode() + + b"\r\n\r\n" + + data + + b"\r\n" ) - return StreamingResponse(frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}") + return StreamingResponse( + frame_generator(), + media_type=f"multipart/x-mixed-replace; boundary={boundary}", + ) except HTTPException: raise except Exception as e: @@ -72,34 +198,46 @@ async def frame_generator(): @router.get("/debug/camera/stream-lossless") async def stream_debug_camera_lossless(): - """无损质量实时流 - 使用PNG格式展示超采样效果""" + """无损质量实时流 - 使用PNG格式展示超采样效果 / Lossless quality live streaming - using PNG format to demonstrate supersampling effects""" try: - from ogscope.web.api.debug.services import DebugCameraService - camera = DebugCameraService.get_camera_instance() - if not camera or not camera.is_capturing: - raise HTTPException(status_code=503, detail="相机未运行") - - import cv2 - import numpy as np - boundary = "frame" + min_emit_interval = 1.0 / max( + 1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8") or "8") + ) async def frame_generator(): + last_snap_frame_id = -1 + last_emit_mono = 0.0 while True: - frame = camera.get_video_frame() - if frame is None: - break - ok, buf = cv2.imencode('.png', frame) - if not ok: + code, data, snap_id = await DebugCameraService.get_stream_frame_bytes( + "png", 100, since_frame_id=last_snap_frame_id + ) + if code == 304: + await asyncio.sleep(0.03) continue - data = buf.tobytes() + if code != 200 or data is None: + await asyncio.sleep(0.05) + continue + now = time.monotonic() + wait = last_emit_mono + min_emit_interval - now + if wait > 0: + await asyncio.sleep(wait) + last_snap_frame_id = snap_id + last_emit_mono = time.monotonic() yield ( b"--" + boundary.encode() + b"\r\n" b"Content-Type: image/png\r\n" - b"Content-Length: " + str(len(data)).encode() + b"\r\n\r\n" + data + b"\r\n" + b"Content-Length: " + + str(len(data)).encode() + + b"\r\n\r\n" + + data + + b"\r\n" ) - return StreamingResponse(frame_generator(), media_type=f"multipart/x-mixed-replace; boundary={boundary}") + return StreamingResponse( + frame_generator(), + media_type=f"multipart/x-mixed-replace; boundary={boundary}", + ) except HTTPException: raise except Exception as e: @@ -108,7 +246,7 @@ async def frame_generator(): @router.post("/debug/camera/stop") async def stop_debug_camera(): - """停止调试相机""" + """停止调试相机 / Stop debugging camera""" try: return await DebugCameraService.stop_camera() except Exception as e: @@ -117,9 +255,10 @@ async def stop_debug_camera(): @router.post("/debug/camera/rotation/{rotation}") async def set_camera_rotation(rotation: int): - """设置相机旋转角度""" + """设置相机旋转角度 / Set camera rotation angle""" try: from ogscope.web.api.debug.services import DebugCameraService + result = await DebugCameraService.set_rotation(rotation) return result except Exception as e: @@ -127,17 +266,29 @@ async def set_camera_rotation(rotation: int): @router.get("/debug/camera/preview") -async def get_debug_camera_preview(): - """获取调试相机预览""" +async def get_debug_camera_preview( + request: Request, + since_frame_id: int | None = Query(default=None), +): + """获取调试相机预览 / Get debug camera preview""" try: - return await DebugCameraService.get_preview() + if _DEBUG_PREVIEW_MIN_INTERVAL_SEC > 0: + client_host = request.client.host if request.client else "unknown" + now = time.monotonic() + last = _PREVIEW_CLIENT_LAST_TS.get(client_host, 0.0) + if now - last < _DEBUG_PREVIEW_MIN_INTERVAL_SEC: + return Response(status_code=304) + _PREVIEW_CLIENT_LAST_TS[client_host] = now + return await DebugCameraService.get_preview(since_frame_id=since_frame_id) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/debug/camera/capture") async def capture_debug_image(): - """拍摄单张图片""" + """拍摄单张图片 / Take a single picture""" try: return await DebugCameraService.capture_image() except Exception as e: @@ -146,7 +297,7 @@ async def capture_debug_image(): @router.post("/debug/camera/record/start") async def start_debug_recording(): - """开始录制视频""" + """开始录制视频 / Start recording video""" try: return await DebugCameraService.start_recording() except Exception as e: @@ -155,7 +306,7 @@ async def start_debug_recording(): @router.post("/debug/camera/record/stop") async def stop_debug_recording(): - """停止录制视频""" + """停止录制视频 / Stop recording video""" try: return await DebugCameraService.stop_recording() except Exception as e: @@ -163,10 +314,13 @@ async def stop_debug_recording(): @router.post("/debug/camera/size") -async def set_camera_size(width: int = Query(..., gt=0), height: int = Query(..., gt=0)): - """仅切换分辨率(宽高),不影响当前帧率;必要时重启预览""" +async def set_camera_size( + width: int = Query(..., gt=0), height: int = Query(..., gt=0) +): + """仅切换分辨率(宽高),不影响当前帧率;必要时重启预览 / Only switches the resolution (width and height) and does not affect the current frame rate; restart the preview if necessary""" try: from ogscope.web.api.debug.services import DebugCameraService + result = await DebugCameraService.set_size(width, height) return result except Exception as e: @@ -174,10 +328,13 @@ async def set_camera_size(width: int = Query(..., gt=0), height: int = Query(... @router.post("/debug/camera/sampling") -async def set_camera_sampling_mode(mode: str = Query(..., pattern="^(supersample|native|crop)$")): +async def set_camera_sampling_mode( + mode: str = Query(..., pattern="^(supersample|native|crop)$") +): """设置采样模式:supersample | native | crop""" try: from ogscope.web.api.debug.services import DebugCameraService + return await DebugCameraService.set_sampling_mode(mode) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -185,169 +342,115 @@ async def set_camera_sampling_mode(mode: str = Query(..., pattern="^(supersample @router.post("/debug/camera/fps") async def set_camera_fps(fps: int = Query(..., gt=0)): - """仅设置帧率,尽量不影响当前预览""" + """仅设置帧率,尽量不影响当前预览 / Only set the frame rate and try not to affect the current preview""" try: from ogscope.web.api.debug.services import DebugCameraService + return await DebugCameraService.set_fps(fps) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + @router.post("/debug/camera/settings") async def update_debug_camera_settings(settings: CameraSettings): - """更新调试相机设置""" + """更新调试相机设置 / Update debug camera settings""" try: return await DebugCameraService.update_settings(settings.dict()) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +@router.post("/debug/camera/auto-exposure") +async def set_debug_camera_auto_exposure(enabled: bool = Query(...)): + """仅切换自动曝光模式 / Toggle auto-exposure mode only""" + try: + return await DebugCameraService.set_auto_exposure_mode(enabled) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/debug/camera/reset") async def reset_debug_camera(): - """重置相机到默认设置""" + """重置相机到默认设置 / Reset camera to default settings""" try: return await DebugCameraService.reset_camera() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -@router.get("/debug/camera/verify-supersample") -async def verify_supersample_settings(): - """验证超采样设置的有效性""" - try: - from ogscope.web.api.debug.services import get_camera_instance - - camera = get_camera_instance() - if not camera or not camera.is_initialized: - raise HTTPException(status_code=500, detail="相机未初始化") - - # 获取相机详细信息 - info = camera.get_camera_info() - - # 验证超采样设置 - verification_result = { - "sampling_mode": info.get('sampling_mode', 'unknown'), - "capture_resolution": f"{info.get('capture_width', 0)}x{info.get('capture_height', 0)}", - "output_resolution": f"{info.get('output_width', 0)}x{info.get('output_height', 0)}", - "is_supersample_active": False, - "supersample_ratio": 1.0, - "verification_status": "unknown", - "recommendations": [] - } - - # 检查超采样是否有效 - if info.get('sampling_mode') == 'supersample': - capture_width = info.get('capture_width', 0) - capture_height = info.get('capture_height', 0) - output_width = info.get('output_width', 0) - output_height = info.get('output_height', 0) - - if capture_width > 0 and capture_height > 0 and output_width > 0 and output_height > 0: - verification_result["is_supersample_active"] = True - - # 计算超采样比例 - width_ratio = capture_width / output_width - height_ratio = capture_height / output_height - verification_result["supersample_ratio"] = min(width_ratio, height_ratio) - - # 验证状态 - if width_ratio >= 1.5 and height_ratio >= 1.5: - verification_result["verification_status"] = "excellent" - elif width_ratio >= 1.2 and height_ratio >= 1.2: - verification_result["verification_status"] = "good" - elif width_ratio > 1.0 and height_ratio > 1.0: - verification_result["verification_status"] = "moderate" - else: - verification_result["verification_status"] = "poor" - verification_result["recommendations"].append("超采样比例过低,建议增加捕获分辨率或减少输出分辨率") - else: - verification_result["verification_status"] = "error" - verification_result["recommendations"].append("无法获取有效的分辨率信息") - else: - verification_result["verification_status"] = "not_supersample" - verification_result["recommendations"].append("当前不是超采样模式,请设置为 supersample 模式") - - # 添加详细建议 - if verification_result["sampling_mode"] == "supersample" and verification_result["is_supersample_active"]: - if verification_result["supersample_ratio"] < 1.2: - verification_result["recommendations"].append("超采样比例较低,图像质量提升有限") - elif verification_result["supersample_ratio"] > 3.0: - verification_result["recommendations"].append("超采样比例很高,可能影响性能,建议适当降低") - - verification_result["recommendations"].append("超采样设置正常,视频流将使用降采样后的高质量图像") - - return { - "success": True, - "verification": verification_result, - "camera_info": info - } - - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/debug/camera/test-image-size") -async def test_image_size(): - """测试捕获图像的实际尺寸,验证超采样是否生效""" - try: - from ogscope.web.api.debug.services import get_camera_instance - - camera = get_camera_instance() - if not camera or not camera.is_initialized: - raise HTTPException(status_code=500, detail="相机未初始化") - - # 捕获一张图像 - image = camera.capture_image() - if image is None: - raise HTTPException(status_code=500, detail="无法捕获图像") - - # 获取图像尺寸信息 - actual_height, actual_width = image.shape[:2] - info = camera.get_camera_info() - - test_result = { - "actual_image_size": f"{actual_width}x{actual_height}", - "expected_output_size": f"{info.get('output_width', 0)}x{info.get('output_height', 0)}", - "expected_capture_size": f"{info.get('capture_width', 0)}x{info.get('capture_height', 0)}", - "sampling_mode": info.get('sampling_mode', 'unknown'), - "size_match": False, - "supersample_working": False, - "analysis": "" - } - - # 分析结果 - expected_width = info.get('output_width', 0) - expected_height = info.get('output_height', 0) - - if actual_width == expected_width and actual_height == expected_height: - test_result["size_match"] = True - test_result["analysis"] = "图像尺寸与预期输出尺寸完全匹配" - - if info.get('sampling_mode') == 'supersample': - capture_width = info.get('capture_width', 0) - capture_height = info.get('capture_height', 0) - if capture_width > expected_width and capture_height > expected_height: - test_result["supersample_working"] = True - test_result["analysis"] += ",超采样功能正常工作" - else: - test_result["analysis"] += ",但超采样可能未正确配置" - else: - test_result["analysis"] = f"图像尺寸不匹配!实际: {actual_width}x{actual_height}, 预期: {expected_width}x{expected_height}" - - return { - "success": True, - "test_result": test_result, - "camera_info": info - } - +@router.post("/debug/camera/night-mode") +async def set_night_mode(enabled: bool = Query(True)): + """设置夜间模式 / Set night mode""" + try: + return await DebugCameraService.set_night_mode(enabled) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/debug/camera/image-quality") +async def get_image_quality(): + """获取图像质量指标 / Get image quality metrics""" + try: + return await DebugCameraService.get_image_quality() except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ==================== 预设管理 ==================== +@router.post("/debug/camera/night-mode-preset") +async def apply_night_mode_preset(): + """应用夜间模式预设 / Apply night mode preset""" + try: + return await DebugCameraService.apply_night_mode_preset() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/backup-settings") +async def backup_camera_settings(): + """备份当前相机设置 / Back up current camera settings""" + try: + return await DebugCameraService.save_current_settings_backup() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/restore-settings") +async def restore_camera_settings(): + """从备份恢复相机设置 / Restore camera settings from backup""" + try: + return await DebugCameraService.restore_settings_backup() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/color-mode") +async def set_camera_color_mode(color_mode: str = Query(..., pattern="^(color|mono)$")): + """设置相机颜色模式 / Set camera color mode""" + try: + return await DebugCameraService.set_color_mode(color_mode) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/camera/white-balance") +async def set_camera_white_balance( + mode: str = Query(..., pattern="^(auto|manual|night)$"), + gain_r: float = Query(1.0, ge=0.1, le=3.0), + gain_b: float = Query(1.0, ge=0.1, le=3.0), +): + """设置白平衡模式 / Set white balance mode""" + try: + return await DebugCameraService.set_white_balance(mode, gain_r, gain_b) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 预设管理 ==================== / ==================== Default Management ==================== + @router.get("/debug/camera/presets") async def get_camera_presets(): - """获取相机预设列表""" + """获取相机预设列表 / Get a list of camera presets""" try: return await DebugPresetService.get_presets() except Exception as e: @@ -356,7 +459,7 @@ async def get_camera_presets(): @router.post("/debug/camera/presets") async def save_camera_preset(preset: CameraPreset): - """保存相机预设""" + """保存相机预设 / Save camera presets""" try: return await DebugPresetService.save_preset(preset.dict()) except Exception as e: @@ -365,7 +468,7 @@ async def save_camera_preset(preset: CameraPreset): @router.post("/debug/camera/presets/{preset_name}/apply") async def apply_camera_preset(preset_name: str): - """应用相机预设""" + """应用相机预设 / Apply camera presets""" try: return await DebugPresetService.apply_preset(preset_name) except Exception as e: @@ -374,18 +477,19 @@ async def apply_camera_preset(preset_name: str): @router.delete("/debug/camera/presets/{preset_name}") async def delete_camera_preset(preset_name: str): - """删除相机预设""" + """删除相机预设 / Delete camera preset""" try: return await DebugPresetService.delete_preset(preset_name) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) -# ==================== 文件管理 ==================== +# ==================== 文件管理 ==================== / ==================== File Management ==================== + @router.get("/debug/files") async def get_capture_files(): - """获取拍摄文件列表""" + """获取拍摄文件列表 / Get shooting file list""" try: return await DebugFileService.get_files() except Exception as e: @@ -394,25 +498,113 @@ async def get_capture_files(): @router.get("/debug/files/{filename}") async def download_capture_file(filename: str): - """下载拍摄文件""" + """下载拍摄文件 / Download shooting files""" from pathlib import Path + DEBUG_CAPTURES_DIR = Path.home() / "dev_captures" file_path = DEBUG_CAPTURES_DIR / filename - + if not file_path.exists(): raise HTTPException(status_code=404, detail="文件不存在") - + return FileResponse( - path=str(file_path), - filename=filename, - media_type="application/octet-stream" + path=str(file_path), filename=filename, media_type="application/octet-stream" ) @router.get("/debug/files/{filename}/info") async def get_file_info(filename: str): - """获取文件信息""" + """获取文件信息 / Get file information""" try: return await DebugFileService.get_file_info(filename) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/debug/files/{filename}") +async def delete_capture_file(filename: str): + """删除拍摄文件 / Delete shooting files""" + try: + return await DebugFileService.delete_file(filename) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 系统日志 ==================== / ==================== System Logs ==================== + + +@router.get("/debug/logs/systemd") +async def get_systemd_logs( + service: str = Query(default="ogscope"), + since_seconds: int = Query(default=600, ge=10, le=86400), + limit: int = Query(default=120, ge=10, le=1000), + levels: str = Query(default="INFO,WARN,ERROR"), +): + """读取 systemd/journalctl 日志 / Read systemd journal logs.""" + level_set = { + part.strip().upper() + for part in levels.split(",") + if part.strip().upper() in {"INFO", "WARN", "ERROR"} + } + if not level_set: + level_set = {"INFO", "WARN", "ERROR"} + try: + rows = await asyncio.to_thread( + _read_journal_logs, + service, + since_seconds, + limit, + ) + filtered = [row for row in rows if str(row.get("level")) in level_set] + return { + "service": service, + "since_seconds": since_seconds, + "limit": limit, + "levels": sorted(level_set), + "items": filtered, + "count": len(filtered), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ==================== 实时解算 ==================== / ==================== Realtime Solving ==================== + + +@router.post("/debug/analysis/realtime/start") +async def start_realtime_solving( + hint_ra_deg: float | None = Query(default=None), + hint_dec_deg: float | None = Query(default=None), + fov_estimate: float | None = Query(default=None), + fov_max_error: float | None = Query(default=None), + solve_timeout_ms: int | None = Query(default=None), +): + """启动实时解算 / Start realtime solving""" + try: + return await realtime_solve_service.start( + hint_ra_deg=hint_ra_deg, + hint_dec_deg=hint_dec_deg, + fov_estimate=fov_estimate, + fov_max_error=fov_max_error, + solve_timeout_ms=solve_timeout_ms, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/debug/analysis/realtime/stop") +async def stop_realtime_solving(): + """停止实时解算 / Stop realtime solving""" + try: + return await realtime_solve_service.stop() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/debug/analysis/realtime/status") +async def get_realtime_solving_status(): + """获取实时解算状态 / Get realtime solving status""" + try: + return await realtime_solve_service.get_status() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/ogscope/web/api/debug/services.py b/ogscope/web/api/debug/services.py index 9082fa4..57863d9 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -1,353 +1,723 @@ """ 调试控制台服务层 """ -import os -import json + import asyncio +import json +import logging +import time from datetime import datetime from pathlib import Path -from typing import Optional, Dict, Any, List +from typing import Any, Optional + +from fastapi import HTTPException -# 调试控制台相关 +from ogscope.web.camera_shared import get_camera_manager + +# 调试控制台相关 / Debug console related DEBUG_CAPTURES_DIR = Path.home() / "dev_captures" DEBUG_CAPTURES_DIR.mkdir(exist_ok=True) -# 全局变量存储相机状态 -camera_instance = None +# 全局变量存储相机状态(相机单例在 CameraManager)/ Global state (camera singleton lives in CameraManager). is_recording = False recording_task = None +recording_state_lock: Optional[asyncio.Lock] = None +# 录制会话元数据(用于停止时写入侧车) / Recording session metadata (for sidecar on stop) +recording_stem: Optional[str] = None +recording_t0_mono: Optional[float] = None +recording_fps_value: float = 15.0 +recording_media_filename: Optional[str] = None +recording_codec_fourcc: str = "MJPG" +recording_container: str = "AVI" + +_CAMERA_ENV_KEY_MAP = { + "width": "OGSCOPE_CAMERA_WIDTH", + "height": "OGSCOPE_CAMERA_HEIGHT", + "fps": "OGSCOPE_CAMERA_FPS", + "sampling_mode": "OGSCOPE_CAMERA_SAMPLING_MODE", + "exposure_us": "OGSCOPE_CAMERA_EXPOSURE", + "analogue_gain": "OGSCOPE_CAMERA_GAIN", +} + +# 串行化 ensure/start,避免并发 to_thread 竞争;与阻塞相机调用分离出事件循环 +# Serialize ensure/start; offload blocking camera calls from asyncio event loop. +_camera_ensure_lock = asyncio.Lock() -# 预览帧缓存与抓取任务 -latest_preview_jpeg: Optional[bytes] = None -last_preview_time: Optional[float] = None -preview_grabber_task = None + +def _get_recording_state_lock() -> asyncio.Lock: + """懒加载录制状态锁 / Lazy-init lock for recording state.""" + global recording_state_lock + if recording_state_lock is None: + recording_state_lock = asyncio.Lock() + return recording_state_lock + + +def is_recording_active() -> bool: + """是否正在录制 / Whether recording is active.""" + return bool(is_recording) + + +def i18n_payload( + message_key: str, message: str, message_params: Optional[dict[str, Any]] = None +) -> dict[str, Any]: + payload: dict[str, Any] = { + "message_key": message_key, + "message": message, + } + if message_params: + payload["message_params"] = message_params + return payload + + +def _persist_env_updates(updates: dict[str, Any]) -> Path: + """将键值写入项目 .env(存在则覆盖,不存在则追加)/ Persist key-values into project .env.""" + env_path = Path.cwd() / ".env" + if env_path.exists(): + lines = env_path.read_text(encoding="utf-8").splitlines() + else: + lines = [] + + pending = {str(k): str(v) for k, v in updates.items()} + new_lines: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + new_lines.append(line) + continue + key, _, _ = line.partition("=") + key = key.strip() + if key in pending: + new_lines.append(f"{key}={pending.pop(key)}") + else: + new_lines.append(line) + for key, value in pending.items(): + new_lines.append(f"{key}={value}") + env_path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + return env_path def get_camera_instance(): - """获取相机实例""" - global camera_instance - if camera_instance is None: - from ogscope.hardware.camera import create_camera - from ogscope.config import get_settings - - settings = get_settings() - config = { - "type": "imx327_mipi", - "width": settings.camera_width, - "height": settings.camera_height, - "fps": 5, # 调试控制台默认使用 5fps(用户未指定时) - "exposure_us": settings.camera_exposure, - "analogue_gain": settings.camera_gain, - "rotation": 180, # 默认180度旋转 - "sampling_mode": "supersample", - } - - camera_instance = create_camera(config) - if camera_instance and not camera_instance.initialize(): - camera_instance = None - - return camera_instance - - -def generate_filename(prefix: str = "IMG") -> str: - """生成文件名""" - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - return f"{prefix}_{timestamp}" - - -def save_capture_info(filename: str, camera_params: Dict[str, Any], file_size: int): - """保存拍摄信息到txt文件""" - info_file = DEBUG_CAPTURES_DIR / f"{filename}.txt" - - info_data = { - "filename": filename, - "timestamp": datetime.now().isoformat(), - "exposure_us": camera_params.get("exposure_us", 0), - "analogue_gain": camera_params.get("analogue_gain", 1.0), - "digital_gain": camera_params.get("digital_gain", 1.0), - "resolution": f"{camera_params.get('width', 1920)}x{camera_params.get('height', 1080)}", - "file_size": file_size, - "camera_type": camera_params.get("type", "imx327_mipi"), - "fps": camera_params.get("fps", 15) + """获取相机实例 / Get camera instance""" + manager = get_camera_manager() + return manager.get_camera_instance() + + +def _attach_manager_camera_if_needed(camera: Any) -> None: + """将兼容层返回的相机实例挂到共享管理器(测试与旧代码兼容)/ Attach compat camera to shared manager.""" + manager = get_camera_manager() + if camera is not None and manager.get_camera_instance() is None: + manager.attach_camera_instance(camera) + + +def _capture_timestamp_for_stem() -> str: + """生成带毫秒的时间戳,降低同秒碰撞 / Timestamp with milliseconds to reduce same-second collisions""" + dt = datetime.now() + return dt.strftime("%Y%m%d_%H%M%S") + f"_{dt.microsecond // 1000:03d}" + + +def _to_json_safe(value: Any) -> Any: + """将嵌套结构转为可 JSON 序列化的类型 / Convert nested structures to JSON-serializable types""" + if isinstance(value, dict): + return {str(k): _to_json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_to_json_safe(v) for v in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return str(value) + + +def build_param_slug(camera_info: dict[str, Any]) -> str: + """从相机信息生成简短、文件名安全的参数片段 / Short filesystem-safe param slug from camera info""" + if not camera_info: + return "" + parts: list[str] = [] + exp = camera_info.get("exposure_us") + if exp is not None: + try: + parts.append(f"e{int(exp)}us") + except (TypeError, ValueError): + pass + ag = camera_info.get("analogue_gain") + if ag is not None: + try: + parts.append(f"ag{float(ag):.1f}".replace(".", "p")) + except (TypeError, ValueError): + pass + dg = camera_info.get("digital_gain") + if dg is not None: + try: + if abs(float(dg) - 1.0) > 0.01: + parts.append(f"dg{float(dg):.1f}".replace(".", "p")) + except (TypeError, ValueError): + pass + fps = camera_info.get("fps") + if fps is not None: + try: + parts.append(f"{float(fps):g}fps") + except (TypeError, ValueError): + pass + sm = camera_info.get("sampling_mode") + if sm and str(sm) != "native": + parts.append(str(sm)[:24]) + ow = camera_info.get("output_width") or camera_info.get("width") + oh = camera_info.get("output_height") or camera_info.get("height") + if ow and oh: + try: + parts.append(f"{int(ow)}x{int(oh)}") + except (TypeError, ValueError): + pass + slug = "_".join(parts) + for bad in '<>:"/\\|?*': + slug = slug.replace(bad, "-") + return slug[:120] + + +def generate_capture_stem(prefix: str, camera_info: dict[str, Any]) -> str: + """生成带参数摘要的文件名主干(无扩展名)/ File stem (no extension) with param summary""" + ts = _capture_timestamp_for_stem() + slug = build_param_slug(camera_info) + if slug: + return f"{prefix}_{ts}_{slug}" + return f"{prefix}_{ts}" + + +def save_capture_sidecar( + stem: str, + camera_params: dict[str, Any], + *, + kind: str, + media_filename: str, + file_size: int, + extra: Optional[dict[str, Any]] = None, +) -> None: + """将完整拍摄/录制参数写入同名 .txt 侧车 / Write full capture params to sidecar .txt file""" + info_file = DEBUG_CAPTURES_DIR / f"{stem}.txt" + payload: dict[str, Any] = { + "kind": kind, + "media_file": media_filename, + "sidecar_version": 2, + "created_at": datetime.now().isoformat(), + "file_size_bytes": file_size, + "camera": _to_json_safe(camera_params), } - - with open(info_file, 'w', encoding='utf-8') as f: - json.dump(info_data, f, indent=2, ensure_ascii=False) + if extra: + payload["extra"] = _to_json_safe(extra) + with open(info_file, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2, ensure_ascii=False) class DebugCameraService: - """调试相机服务""" - + """调试相机服务 / Debug camera service""" + @staticmethod def get_camera_instance(): - """提供给路由的获取实例入口(兼容 routes 中的调用)""" + """提供给路由的获取实例入口(兼容 routes 中的调用) / Obtain instance entry provided for routing (compatible with calls in routes)""" return globals()["get_camera_instance"]() - + @staticmethod async def get_camera_status(): - """获取调试相机状态""" - camera = get_camera_instance() - if not camera: + """获取调试相机状态 / Get debug camera status""" + camera = await asyncio.to_thread(get_camera_instance) + _attach_manager_camera_if_needed(camera) + status = await get_camera_manager().status() + if not status.get("connected"): return { "connected": False, "streaming": False, "recording": is_recording, - "error": "相机未初始化" + "error": "相机未初始化", } - return { - "connected": camera.is_initialized, - "streaming": camera.is_capturing, + "connected": bool(status.get("connected")), + "streaming": bool(status.get("streaming")), "recording": is_recording, - "info": camera.get_camera_info() + "info": status.get("info", {}), + "runtime_overrides": status.get("runtime_overrides", {}), } - + + @staticmethod + async def get_runtime_overrides(): + """获取运行时预览覆盖参数 / Get runtime preview overrides.""" + manager = get_camera_manager() + return {"runtime_overrides": manager.get_runtime_overrides()} + + @staticmethod + async def clear_runtime_overrides(): + """清空运行时预览覆盖参数 / Clear runtime preview overrides.""" + manager = get_camera_manager() + manager.clear_runtime_overrides() + return { + "success": True, + **i18n_payload( + "server.runtimeOverridesCleared", + "运行时预览参数已清空", + ), + } + + @staticmethod + async def apply_runtime_overrides_as_defaults(): + """将运行时覆盖参数确认写入系统默认 .env / Persist runtime overrides to .env defaults.""" + manager = get_camera_manager() + overrides = manager.get_runtime_overrides() + if not overrides: + return { + "success": True, + "applied": {}, + "skipped": {}, + **i18n_payload( + "server.runtimeOverridesEmpty", + "当前没有待确认的运行时参数", + ), + } + applied: dict[str, Any] = {} + skipped: dict[str, Any] = {} + for key, value in overrides.items(): + env_key = _CAMERA_ENV_KEY_MAP.get(key) + if env_key: + applied[env_key] = value + else: + skipped[key] = value + env_path = None + if applied: + env_path = _persist_env_updates(applied) + return { + "success": True, + "applied": applied, + "skipped": skipped, + "env_path": str(env_path) if env_path else None, + **i18n_payload( + "server.runtimeOverridesAppliedAsDefaults", + "运行时参数已写入系统默认配置", + ), + } + @staticmethod async def start_camera(): - """启动调试相机""" - camera = get_camera_instance() - if not camera: - raise Exception("相机初始化失败") - - if camera.start_capture(): - # 启动后台抓取任务 - await DebugCameraService._ensure_preview_grabber() - return {"success": True, "message": "相机启动成功"} - else: - raise Exception("相机启动失败") - + """启动调试相机 / Start the debug camera""" + camera = await asyncio.to_thread(get_camera_instance) + _attach_manager_camera_if_needed(camera) + await get_camera_manager().ensure_started() + return {"success": True, **i18n_payload("server.cameraStarted", "相机启动成功")} + + @staticmethod + async def ensure_camera_streaming(): + """确保相机已采集并刷新预览(分析台与 /api/camera 共用单例,避免重复打开设备)/ Ensure capture + preview; shared singleton for lab and /api/camera.""" + camera = await asyncio.to_thread(get_camera_instance) + _attach_manager_camera_if_needed(camera) + await get_camera_manager().ensure_started() + @staticmethod async def stop_camera(): - """停止调试相机""" - camera = get_camera_instance() - if not camera: - return {"success": True, "message": "相机未运行"} - - if camera.stop_capture(): - await DebugCameraService._stop_preview_grabber() - return {"success": True, "message": "相机停止成功"} - else: - raise Exception("相机停止失败") - + """停止调试相机 / Stop debugging camera""" + camera = await asyncio.to_thread(get_camera_instance) + _attach_manager_camera_if_needed(camera) + await get_camera_manager().stop() + return {"success": True, **i18n_payload("server.cameraStopped", "相机停止成功")} + @staticmethod - async def get_preview(): - """获取调试相机预览""" - camera = get_camera_instance() - if not camera or not camera.is_capturing: - raise Exception("相机未运行") - - try: - # 若后台抓取未运行,尝试启动一次 - await DebugCameraService._ensure_preview_grabber() - - # 等待最多500ms 以获取缓存帧 - import time - deadline = time.time() + 0.5 - global latest_preview_jpeg - while latest_preview_jpeg is None and time.time() < deadline: - await asyncio.sleep(0.01) - if latest_preview_jpeg is None: - raise Exception("暂无预览帧") - from fastapi.responses import StreamingResponse - return StreamingResponse( - iter([latest_preview_jpeg]), - media_type="image/jpeg", - headers={"Cache-Control": "no-cache"} + async def get_preview(since_frame_id: int | None = None): + """获取调试相机预览 / Get debug camera preview""" + from fastapi.responses import Response + + manager = get_camera_manager() + code, frame = await manager.get_preview_frame(since_frame_id) + if code == 304: + return Response(status_code=304) + if code != 200 or frame is None or frame.jpeg_frame is None: + # 预览仅消费共享 JPEG 缓存,避免触发 raw 抓取与二次编码导致内存尖峰 + # Preview consumes shared JPEG cache only; avoid raw grab + re-encode spikes. + raise HTTPException( + status_code=503, + detail="暂无预览帧,请稍后重试 / No preview frame yet, retry shortly", ) - except Exception as e: - raise Exception(f"预览失败: {str(e)}") - + return Response( + content=frame.jpeg_frame, + media_type="image/jpeg", + headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + "Pragma": "no-cache", + "X-Frame-Id": str(frame.frame_id), + "X-Frame-Ts": str(frame.timestamp), + "X-Frame-Width": str(frame.width), + "X-Frame-Height": str(frame.height), + }, + ) + + @staticmethod + async def get_stream_frame_bytes( + image_format: str = "jpeg", + quality: int = 75, + *, + since_frame_id: int | None = None, + ) -> tuple[int, bytes | None, int]: + """读取共享流帧并编码 / Read shared frame and encode. + + 返回 (code, data, snap_frame_id)。snap_frame_id 供 MJPEG 循环写入 since_frame_id, + 在共享缓存未前进时返回 304 以避免 PNG/自定义质量下的疯狂同步抓帧。 + Returns (code, data, snap_frame_id). snap_frame_id is fed back as since_frame_id for + the MJPEG loop; returns 304 when the shared cache has not advanced to avoid sync grabs. + """ + manager = get_camera_manager() + await manager.ensure_started() + snap = await manager.get_cached_frame_snapshot() + if snap is None: + return 503, None, 0 + + fmt = image_format.lower() + q = int(max(10, min(100, int(quality)))) + default_q = int(manager.preview_jpeg_quality) + + if since_frame_id is not None and since_frame_id == snap.frame_id: + return 304, None, snap.frame_id + + if fmt == "jpeg" and q == default_q and snap.jpeg_frame is not None: + return 200, snap.jpeg_frame, snap.frame_id + + raw, _fid, _ts = await manager.get_raw_frame() + encoded = await asyncio.to_thread(manager.encode_frame, raw, image_format, q) + if encoded is None: + return 500, None, snap.frame_id + snap2 = await manager.get_cached_frame_snapshot() + sid = int(snap2.frame_id) if snap2 is not None else int(snap.frame_id) + return 200, encoded, sid + @staticmethod async def capture_image(): - """拍摄单张图片""" - camera = get_camera_instance() + """拍摄单张图片 / Take a single picture""" + manager = get_camera_manager() + await manager.ensure_started() + camera = manager.get_camera_instance() if not camera or not camera.is_capturing: raise Exception("相机未运行") - + try: import cv2 - - # 捕获图像 - image = camera.capture_image() + + try: + image, _rfid, _rts = await manager.get_raw_frame() + except RuntimeError as exc: + raise Exception("图像捕获失败") from exc if image is None: raise Exception("图像捕获失败") - - # 生成文件名 - filename = generate_filename("IMG") - image_path = DEBUG_CAPTURES_DIR / f"{filename}.jpg" - - # 保存图像 + + camera_info = await asyncio.to_thread(camera.get_camera_info) + expected_w = int( + camera_info.get("output_width", camera_info.get("width", 0)) or 0 + ) + expected_h = int( + camera_info.get("output_height", camera_info.get("height", 0)) or 0 + ) + actual_h, actual_w = image.shape[:2] + rotation = int(camera_info.get("rotation", 0) or 0) + if rotation in (90, 270): + expected_w, expected_h = expected_h, expected_w + if ( + expected_w > 0 + and expected_h > 0 + and (int(actual_w) != expected_w or int(actual_h) != expected_h) + ): + raise Exception( + f"拍照分辨率与当前设置不一致: expected={expected_w}x{expected_h}, actual={actual_w}x{actual_h}" + ) + + # 生成文件名(含参数摘要)/ File name with param summary in stem + stem = generate_capture_stem("IMG", camera_info) + image_path = DEBUG_CAPTURES_DIR / f"{stem}.jpg" + + # 保存图像 / save image success = cv2.imwrite(str(image_path), image) if not success: raise Exception("图像保存失败") - - # 保存拍摄信息 - camera_info = camera.get_camera_info() + + # 保存拍摄信息侧车 / Save capture sidecar (.txt) file_size = image_path.stat().st_size - save_capture_info(filename, camera_info, file_size) - + save_capture_sidecar( + stem, + camera_info, + kind="photo", + media_filename=f"{stem}.jpg", + file_size=file_size, + extra={ + "actual_saved_width": int(actual_w), + "actual_saved_height": int(actual_h), + "expected_output_width": int(expected_w), + "expected_output_height": int(expected_h), + }, + ) + return { "success": True, - "filename": f"{filename}.jpg", + "filename": f"{stem}.jpg", "path": str(image_path), - "size": file_size + "size": file_size, + "actual_saved_width": int(actual_w), + "actual_saved_height": int(actual_h), + "expected_output_width": int(expected_w), + "expected_output_height": int(expected_h), } - + except ImportError: raise Exception("OpenCV未安装") except Exception as e: raise Exception(f"拍摄失败: {str(e)}") - + @staticmethod async def set_rotation(rotation: int): - """设置图像旋转角度""" + """设置图像旋转角度 / Set image rotation angle""" camera = get_camera_instance() if not camera: raise Exception("相机未初始化") - + if camera.set_rotation(rotation): - return {"success": True, "message": f"旋转角度设置为: {rotation}度"} + get_camera_manager().update_runtime_overrides({"rotation": int(rotation)}) + return { + "success": True, + **i18n_payload( + "server.rotationSet", + f"旋转角度设置为: {rotation}度", + {"rotation": rotation}, + ), + } else: raise Exception("设置旋转角度失败") - + @staticmethod async def start_recording(): - """开始录制视频""" - global is_recording, recording_task - - if is_recording: - raise Exception("已在录制中") - - camera = get_camera_instance() - if not camera or not camera.is_capturing: - raise Exception("相机未运行") - - try: - import cv2 - import numpy as np - - filename = generate_filename("VID") - video_path = DEBUG_CAPTURES_DIR / f"{filename}.avi" - - # 创建视频写入器(MJPG/AVI 更鲁棒) - fourcc = cv2.VideoWriter_fourcc(*'MJPG') - camera_info = camera.get_camera_info() - width = camera_info.get('width', 1920) - height = camera_info.get('height', 1080) - fps = camera_info.get('fps', 15) - - video_writer = cv2.VideoWriter(str(video_path), fourcc, fps, (width, height)) - - if not video_writer.isOpened(): - raise Exception("视频写入器创建失败") - - is_recording = True - - # 启动录制任务 - async def record_video(): - nonlocal video_writer - try: - while is_recording: - image = camera.capture_image() - if image is not None: - # OpenCV 期望 BGR + """开始录制视频 / Start recording video""" + global is_recording, recording_task, recording_stem, recording_t0_mono, recording_fps_value + global recording_media_filename, recording_codec_fourcc, recording_container + + async with _get_recording_state_lock(): + if is_recording: + raise Exception("已在录制中") + try: + from ogscope.web.api.analysis.services import analysis_service + + if analysis_service.is_realtime_source_busy("camera"): + raise Exception("画面解析进行中,无法开始录制") + except ImportError: + pass + + camera = get_camera_instance() + if not camera or not camera.is_capturing: + raise Exception("相机未运行") + + try: + import cv2 + + camera_info = camera.get_camera_info() + stem = generate_capture_stem("VID", camera_info) + video_path = DEBUG_CAPTURES_DIR / f"{stem}.avi" + + # 优先使用 AVI 友好编码,按候选顺序探测可用编码器 / Prefer AVI-friendly codecs. + codec_candidates = [ + ("MJPG", "AVI"), + ("XVID", "AVI"), + ("DIVX", "AVI"), + ] + width = int( + camera_info.get("output_width", camera_info.get("width", 1920)) + ) + height = int( + camera_info.get("output_height", camera_info.get("height", 1080)) + ) + # 将录制写盘帧率限制在 1-3 FPS,进一步降低开发板负载 / Clamp record-write FPS to 1-3. + source_fps = float(camera_info.get("fps", 15)) + fps = max(1.0, min(3.0, source_fps)) + recording_fps_value = fps + + video_writer = None + chosen_codec = None + chosen_container = None + for codec_tag, container in codec_candidates: + fourcc = cv2.VideoWriter_fourcc(*codec_tag) + candidate_writer = cv2.VideoWriter( + str(video_path), fourcc, fps, (width, height) + ) + if candidate_writer.isOpened(): + video_writer = candidate_writer + chosen_codec = codec_tag + chosen_container = container + break + candidate_writer.release() + + if video_writer is None or not video_writer.isOpened(): + raise Exception("视频写入器创建失败(AVI编码器不可用)") + + recording_stem = stem + recording_t0_mono = time.monotonic() + recording_media_filename = f"{stem}.avi" + recording_codec_fourcc = str(chosen_codec or "MJPG") + recording_container = str(chosen_container or "AVI") + is_recording = True + + async def record_video(): + nonlocal video_writer + try: + mgr = get_camera_manager() + while is_recording: try: - import cv2 - bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) - except Exception: - bgr = image - video_writer.write(bgr) - await asyncio.sleep(1/max(fps,1)) - finally: - video_writer.release() - - recording_task = asyncio.create_task(record_video()) - - return { - "success": True, - "filename": f"{filename}.avi", - "path": str(video_path) - } - - except ImportError: - raise Exception("OpenCV未安装") - except Exception as e: - raise Exception(f"录制启动失败: {str(e)}") - + image, _, _ = await mgr.get_raw_frame() + except RuntimeError: + image = None + if image is not None: + try: + bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + except Exception: + bgr = image + video_writer.write(bgr) + await asyncio.sleep(1 / max(fps, 1)) + finally: + video_writer.release() + + recording_task = asyncio.create_task(record_video()) + return { + "success": True, + "filename": f"{stem}.avi", + "path": str(video_path), + } + except ImportError: + raise Exception("OpenCV未安装") + except Exception as e: + raise Exception(f"录制启动失败: {str(e)}") + @staticmethod async def stop_recording(): - """停止录制视频""" - global is_recording, recording_task - - if not is_recording: - raise Exception("未在录制中") - - is_recording = False - - if recording_task: - await recording_task - recording_task = None - - return {"success": True, "message": "录制已停止"} + """停止录制视频 / Stop recording video""" + global is_recording, recording_task, recording_stem, recording_t0_mono, recording_fps_value + global recording_media_filename, recording_codec_fourcc, recording_container + + async with _get_recording_state_lock(): + if not is_recording: + raise Exception("未在录制中") + + stem = recording_stem + t0 = recording_t0_mono + nominal_fps = recording_fps_value + media_filename = recording_media_filename + codec_fourcc = recording_codec_fourcc + container = recording_container + + is_recording = False + + if recording_task: + try: + # 最多等待短时间优雅结束,避免停止录制长时间卡住 / Wait briefly for graceful stop. + await asyncio.wait_for(recording_task, timeout=2.0) + except asyncio.TimeoutError: + recording_task.cancel() + try: + await asyncio.wait_for(recording_task, timeout=1.0) + except asyncio.CancelledError: + pass + except Exception as e: + logging.getLogger(__name__).warning( + "停止录制时等待录制任务取消异常: %s", e + ) + except asyncio.CancelledError: + pass + recording_task = None + + if stem: + media_filename = media_filename or f"{stem}.avi" + video_path = DEBUG_CAPTURES_DIR / media_filename + duration_s = 0.0 + if t0 is not None: + duration_s = max(0.0, time.monotonic() - t0) + file_size = int(video_path.stat().st_size) if video_path.exists() else 0 + camera = get_camera_instance() + camera_info = camera.get_camera_info() if camera else {} + save_capture_sidecar( + stem, + camera_info, + kind="video", + media_filename=media_filename, + file_size=file_size, + extra={ + "duration_s": round(duration_s, 3), + "nominal_fps": nominal_fps, + "codec_fourcc": codec_fourcc, + "container": container, + }, + ) + recording_stem = None + recording_t0_mono = None + recording_media_filename = None + recording_codec_fourcc = "MJPG" + recording_container = "AVI" + + return { + "success": True, + **i18n_payload("server.recordingStopped", "录制已停止"), + } @staticmethod async def set_size(width: int, height: int): - """仅切换分辨率(宽高),不影响当前帧率;必要时重启预览抓取""" + """仅切换分辨率(宽高),不影响当前帧率;必要时重启预览抓取 / Only switches the resolution (width and height) without affecting the current frame rate; restart preview capture if necessary""" camera = get_camera_instance() if not camera or not camera.is_initialized: raise Exception("相机未初始化") - - # 验证输入参数 + + # 验证输入参数 / Validate input parameters if width <= 0 or height <= 0: raise Exception("分辨率参数无效") - - # 为避免在预览抓取进行中重配导致底层冲突:先停抓取,再设置,最后重启抓取 + + # 检查当前分辨率是否相同 / Check if the current resolutions are the same + info = camera.get_camera_info() + current_width = info.get("output_width", info.get("width", 0)) + current_height = info.get("output_height", info.get("height", 0)) + + if current_width == width and current_height == height: + return { + "success": True, + "info": info, + **i18n_payload("server.resolutionUnchanged", "分辨率未变化"), + } + try: - await DebugCameraService._stop_preview_grabber() - success = camera.set_resolution(int(width), int(height)) + success = await get_camera_manager().reconfigure_camera( + "set_resolution", + lambda: camera.set_resolution(int(width), int(height)), + timeout_sec=10.0, + ) if not success: raise Exception("相机设置分辨率失败") + except asyncio.TimeoutError: + raise Exception("设置分辨率超时,请重试") except Exception as e: - # 出错也尽量恢复抓取器 - try: - await DebugCameraService._ensure_preview_grabber() - except Exception: - pass raise Exception(f"设置分辨率失败: {str(e)}") - # 校验是否已生效(以相机报告的尺寸为准) + # 校验是否已生效(以相机报告的尺寸为准) / Verify whether the verification has taken effect (subject to the size reported by the camera) info = camera.get_camera_info() - # 在supersample模式下,检查output_width和output_height - if info.get('sampling_mode') == 'supersample': - applied = (int(info.get('output_width', 0)) == int(width) and int(info.get('output_height', 0)) == int(height)) + # 在supersample模式下,检查output_width和output_height / In supersample mode, check output_width and output_height + if info.get("sampling_mode") == "supersample": + applied = int(info.get("output_width", 0)) == int(width) and int( + info.get("output_height", 0) + ) == int(height) else: - applied = (int(info.get('width', 0)) == int(width) and int(info.get('height', 0)) == int(height)) - + applied = int(info.get("width", 0)) == int(width) and int( + info.get("height", 0) + ) == int(height) + if not applied: - # 如果设置未生效,尝试重新设置一次 - try: - success = camera.set_resolution(int(width), int(height)) - if success: - info = camera.get_camera_info() - if info.get('sampling_mode') == 'supersample': - applied = (int(info.get('output_width', 0)) == int(width) and int(info.get('output_height', 0)) == int(height)) - else: - applied = (int(info.get('width', 0)) == int(width) and int(info.get('height', 0)) == int(height)) - except Exception: - pass - - if not applied: - current_res = f"{info.get('width', 0)}x{info.get('height', 0)}" - if info.get('sampling_mode') == 'supersample': - current_res = f"{info.get('output_width', 0)}x{info.get('output_height', 0)}" - raise Exception(f"切换分辨率未生效,当前分辨率: {current_res}") - - # 分辨率调整后尝试重启抓取器(失败不影响返回) - try: - await DebugCameraService._restart_preview_grabber() - except Exception: - pass - return {"success": True, "message": "分辨率已更新", "info": info} + # 如果设置未生效,记录警告但不抛出异常 / If the setting does not take effect, log a warning but do not throw an exception + current_res = f"{info.get('width', 0)}x{info.get('height', 0)}" + if info.get("sampling_mode") == "supersample": + current_res = ( + f"{info.get('output_width', 0)}x{info.get('output_height', 0)}" + ) + logging.getLogger(__name__).warning( + f"分辨率设置可能未完全生效,当前分辨率: {current_res}" + ) + + get_camera_manager().update_runtime_overrides( + {"width": int(width), "height": int(height)} + ) + return { + "success": True, + "info": info, + **i18n_payload("server.resolutionUpdated", "分辨率已更新"), + } @staticmethod async def set_sampling_mode(mode: str): @@ -355,354 +725,842 @@ async def set_sampling_mode(mode: str): camera = get_camera_instance() if not camera or not camera.is_initialized: raise Exception("相机未初始化") - - # 验证输入参数 - if mode not in ['supersample', 'native', 'crop']: + + # 验证输入参数 / Validate input parameters + if mode not in ["supersample", "native", "crop"]: raise Exception(f"不支持的采样模式: {mode}") - - # 避免与预览抓取竞争:先停抓取 + try: - await DebugCameraService._stop_preview_grabber() - ok = camera.set_sampling_mode(mode) + ok = await get_camera_manager().reconfigure_camera( + "set_sampling_mode", + lambda: camera.set_sampling_mode(mode), + timeout_sec=10.0, + ) if not ok: raise Exception("相机设置采样模式失败") except Exception as e: - try: - await DebugCameraService._ensure_preview_grabber() - except Exception: - pass raise Exception(f"设置采样模式失败: {str(e)}") - - # 验证设置是否生效 + + # 验证设置是否生效 / Verify whether the settings take effect info = camera.get_camera_info() - current_mode = info.get('sampling_mode', 'unknown') - if current_mode != mode: + current_mode = info.get("sampling_mode", "unknown") + requested_mode = mode + if requested_mode == "supersample" and current_mode == "native": + # 在高分辨率场景下会自动降级为 native,这是预期行为 / In high-resolution scenarios, it is expected to automatically downgrade to native. + pass + elif current_mode != requested_mode: raise Exception(f"采样模式设置未生效,当前模式: {current_mode}") - - await DebugCameraService._restart_preview_grabber() - return {"success": True, "message": f"采样模式已设置为 {mode}", "info": info} + + get_camera_manager().update_runtime_overrides({"sampling_mode": mode}) + return { + "success": True, + "info": info, + "requested_mode": requested_mode, + "effective_mode": current_mode, + **i18n_payload( + "server.samplingModeSet", + f"采样模式请求为 {requested_mode},实际生效为 {current_mode}", + {"requested_mode": requested_mode, "effective_mode": current_mode}, + ), + } @staticmethod async def set_fps(fps: int): - """仅设置帧率,尽量不影响当前预览""" + """仅设置帧率,尽量不影响当前预览 / Only set the frame rate and try not to affect the current preview""" camera = get_camera_instance() if not camera or not camera.is_initialized: raise Exception("相机未初始化") - - # 验证输入参数 + + # 验证输入参数 / Validate input parameters if fps <= 0 or fps > 60: raise Exception(f"帧率参数无效: {fps} (应在1-60之间)") - + try: ok = False - # 优先热更新帧率(同步调用,避免执行器上下文问题) - if hasattr(camera, 'set_fps'): - ok = camera.set_fps(int(fps)) + if hasattr(camera, "set_fps"): + ok = await get_camera_manager().reconfigure_camera( + "set_fps", + lambda: camera.set_fps(int(fps)), + timeout_sec=10.0, + ) else: - # 兼容旧实现:通过 set_resolution 传入 fps info = camera.get_camera_info() - # 为避免竞争,切换前停抓取 - await DebugCameraService._stop_preview_grabber() - ok = camera.set_resolution(info.get('width', 640), info.get('height', 360), int(fps)) + ok = await get_camera_manager().reconfigure_camera( + "set_fps_by_set_resolution", + lambda: camera.set_resolution( + info.get("width", 640), info.get("height", 360), int(fps) + ), + timeout_sec=10.0, + ) if not ok: raise Exception("相机设置帧率失败") - - # 验证设置是否生效 + + # 验证设置是否生效 / Verify whether the settings take effect info = camera.get_camera_info() - current_fps = info.get('fps', 0) + current_fps = info.get("fps", 0) if current_fps != int(fps): - # 如果设置未生效,尝试重新设置一次 + # 如果设置未生效,尝试重新设置一次 / If the setting does not take effect, try setting it again try: - if hasattr(camera, 'set_fps'): - ok = camera.set_fps(int(fps)) + if hasattr(camera, "set_fps"): + ok = await get_camera_manager().reconfigure_camera( + "retry_set_fps", + lambda: camera.set_fps(int(fps)), + timeout_sec=10.0, + ) else: - ok = camera.set_resolution(info.get('width', 640), info.get('height', 360), int(fps)) + ok = await get_camera_manager().reconfigure_camera( + "retry_set_fps_by_set_resolution", + lambda: camera.set_resolution( + info.get("width", 640), + info.get("height", 360), + int(fps), + ), + timeout_sec=10.0, + ) if ok: info = camera.get_camera_info() - current_fps = info.get('fps', 0) + current_fps = info.get("fps", 0) except Exception: pass - + if current_fps != int(fps): raise Exception(f"帧率设置未生效,当前帧率: {current_fps}") - - # 帧率变化后,预览抓取节流需要同步 - await DebugCameraService._restart_preview_grabber() - return {"success": True, "message": f"帧率设置为 {int(fps)}", "info": info} + + get_camera_manager().update_runtime_overrides({"fps": int(fps)}) + return { + "success": True, + "info": info, + **i18n_payload( + "server.fpsSet", f"帧率设置为 {int(fps)}", {"fps": int(fps)} + ), + } except Exception as e: raise Exception(f"设置帧率失败: {str(e)}") - # ==================== 内部:预览抓取器 ==================== + # ==================== 内部:预览抓取器 ==================== / ==================== Internal: Preview Grabber ==================== @staticmethod async def _ensure_preview_grabber(): - global preview_grabber_task - if preview_grabber_task and not preview_grabber_task.done(): - return - preview_grabber_task = asyncio.create_task(DebugCameraService._preview_grabber_loop()) + await get_camera_manager().resume_grabber() @staticmethod async def _stop_preview_grabber(): - global preview_grabber_task - if preview_grabber_task: - preview_grabber_task.cancel() - try: - # 添加超时机制,避免无限等待 - await asyncio.wait_for(preview_grabber_task, timeout=2.0) - except asyncio.TimeoutError: - # 超时后强制取消 - preview_grabber_task.cancel() - except asyncio.CancelledError: - # 任务被取消是正常的,不需要处理 - pass - except Exception: - pass - preview_grabber_task = None + await get_camera_manager().pause_grabber() @staticmethod async def _restart_preview_grabber(): - await DebugCameraService._stop_preview_grabber() - await DebugCameraService._ensure_preview_grabber() + await get_camera_manager().pause_grabber() + await get_camera_manager().resume_grabber() @staticmethod - async def _preview_grabber_loop(): - """后台抓取最新帧,编码为 JPEG 缓存,降低单次请求阻塞与抖动""" - global latest_preview_jpeg, last_preview_time + async def set_auto_exposure_mode(enabled: bool): + """仅切换自动曝光模式 / Toggle auto-exposure mode only""" camera = get_camera_instance() - if not camera or not camera.is_capturing: - return - import cv2 - import time - target_fps = max(1, int(camera.get_camera_info().get('fps', 5))) - interval = 1.0 / target_fps - try: - while True: - start = time.time() - try: - image = camera.get_video_frame() - if image is not None: - ok, buf = cv2.imencode('.jpg', image, [cv2.IMWRITE_JPEG_QUALITY, 85]) - if ok: - latest_preview_jpeg = buf.tobytes() - last_preview_time = time.time() - except Exception: - # 忽略单帧失败 - pass - # 按 fps 节流 - spent = time.time() - start - await asyncio.sleep(max(0.0, interval - spent)) - except asyncio.CancelledError: - # 正确处理取消信号 - raise - except Exception as e: - # 记录其他异常 - import logging - logging.getLogger(__name__).error(f"预览抓取器异常: {e}") - + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if not hasattr(camera, "set_auto_exposure"): + raise Exception("当前相机不支持自动曝光切换") + + if not camera.set_auto_exposure(bool(enabled)): + raise Exception("设置自动曝光模式失败") + + get_camera_manager().update_runtime_overrides({"auto_exposure": bool(enabled)}) + return { + "success": True, + **i18n_payload("server.autoExposureUpdated", "曝光模式已更新"), + "auto_exposure": bool(enabled), + } + @staticmethod - async def update_settings(settings: Dict[str, Any]): - """更新调试相机设置""" + async def update_settings(settings: dict[str, Any]): + """更新调试相机设置 / Update debug camera settings""" camera = get_camera_instance() if not camera or not camera.is_initialized: raise Exception("相机未初始化") - + try: - # 更新相机参数 - camera.set_exposure(settings["exposure"]) - camera.set_gain(settings["gain"]) - + # 优先处理自动曝光开关,避免自动 / 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) + + if hasattr(camera, "set_image_enhancement"): + camera.set_image_enhancement( + contrast, brightness, saturation, sharpness + ) + + # 更新降噪设置 / Update noise reduction settings + if "noiseReduction" in settings: + if hasattr(camera, "set_noise_reduction"): + camera.set_noise_reduction(settings["noiseReduction"]) + + # 更新白平衡设置 / 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 hasattr(camera, "set_white_balance"): + camera.set_white_balance(mode, gain_r, gain_b) + + # 更新颜色模式设置 / 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, + ) + + overrides: dict[str, Any] = {} + if "exposure" in settings: + overrides["exposure_us"] = settings["exposure"] + if "gain" in settings: + overrides["analogue_gain"] = settings["gain"] + if "digitalGain" in settings: + overrides["digital_gain"] = settings["digitalGain"] + if "autoExposure" in settings: + overrides["auto_exposure"] = bool(settings["autoExposure"]) + if "colorMode" in settings: + overrides["color_mode"] = settings["colorMode"] + if overrides: + get_camera_manager().update_runtime_overrides(overrides) + return { "success": True, - "message": "相机设置已更新", - "settings": settings + **i18n_payload("server.cameraSettingsUpdated", "相机设置已更新"), + "settings": settings, } except Exception as e: raise Exception(f"更新设置失败: {str(e)}") - + @staticmethod async def reset_camera(): - """重置相机到默认设置""" + """重置相机到默认设置 / Reset camera to default settings""" from ogscope.config import get_settings - + settings = get_settings() camera = get_camera_instance() - + if camera and camera.is_initialized: camera.set_exposure(settings.camera_exposure) camera.set_gain(settings.camera_gain) - + return { "success": True, - "message": "相机已重置到默认设置" + **i18n_payload("server.cameraReset", "相机已重置到默认设置"), } + @staticmethod + async def get_image_quality(): + """获取图像质量指标 / Get image quality metrics""" + # 仅使用当前已存在实例,不触发懒初始化,避免后台轮询造成反复 acquire 冲突 + # Use existing instance only; avoid lazy init from background polling. + camera = get_camera_manager().get_camera_instance() + if camera is None: + # 测试环境兼容:允许使用 monkeypatch 注入的相机实例 + # Test compatibility: allow monkeypatched injected camera instance. + try: + camera = get_camera_instance() + _attach_manager_camera_if_needed(camera) + except Exception: + camera = None + if not camera or not getattr(camera, "is_initialized", False): + return { + "success": False, + "available": False, + "quality": { + "noise_level": 0.0, + "exposure_adequacy": 0.0, + "gain_level": 0.0, + }, + **i18n_payload("server.cameraNotRunning", "相机未运行"), + } + quality_metrics = camera.get_image_quality_metrics() + return {"success": True, "available": True, "quality": quality_metrics} + + @staticmethod + async def set_noise_reduction(level: int): + """设置降噪级别 / Set noise reduction level""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if camera.set_noise_reduction(level): + return { + "success": True, + **i18n_payload( + "server.noiseReductionSet", + f"降噪级别设置为: {level}", + {"level": level}, + ), + } + else: + raise Exception("设置降噪级别失败") + + @staticmethod + async def set_white_balance(mode: str, gain_r: float = 1.0, gain_b: float = 1.0): + """设置白平衡 / Set white balance""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if camera.set_white_balance(mode, gain_r, gain_b): + return { + "success": True, + **i18n_payload( + "server.whiteBalanceSet", + f"白平衡模式设置为: {mode}", + {"mode": mode}, + ), + } + else: + raise Exception("设置白平衡失败") + + @staticmethod + async def set_image_enhancement( + contrast: float = 1.0, + brightness: float = 0.0, + saturation: float = 1.0, + sharpness: float = 1.0, + ): + """设置图像增强参数 / Set image enhancement parameters""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if camera.set_image_enhancement(contrast, brightness, saturation, sharpness): + return { + "success": True, + **i18n_payload("server.imageEnhancementSet", "图像增强参数已设置"), + } + else: + raise Exception("设置图像增强参数失败") + + @staticmethod + async def set_night_mode(enabled: bool): + """设置夜间模式 / Set night mode""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if camera.set_night_mode(enabled): + mode_text = "启用" if enabled else "关闭" + return { + "success": True, + **i18n_payload( + "server.nightModeSet", + f"夜间模式已{mode_text}", + {"state": mode_text}, + ), + } + else: + raise Exception("设置夜间模式失败") + + @staticmethod + async def apply_night_mode_preset(): + """应用夜间模式预设 / Apply night mode preset""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + try: + # 夜间模式预设参数 / Night mode preset parameters + night_preset = { + "exposure_us": 50000, + "analogue_gain": 8.0, + "digital_gain": 2.0, + "noise_reduction": 2, + "white_balance_mode": "night", + "contrast": 1.2, + "brightness": 0.1, + "saturation": 0.8, + "sharpness": 1.1, + "night_mode": True, + } + + # 应用预设 / Apply preset + camera.set_exposure(night_preset["exposure_us"]) + camera.set_gain(night_preset["analogue_gain"], night_preset["digital_gain"]) + camera.set_noise_reduction(night_preset["noise_reduction"]) + camera.set_white_balance(night_preset["white_balance_mode"]) + camera.set_image_enhancement( + night_preset["contrast"], + night_preset["brightness"], + night_preset["saturation"], + night_preset["sharpness"], + ) + camera.set_night_mode(night_preset["night_mode"]) + + return { + "success": True, + "preset": night_preset, + **i18n_payload("server.nightPresetApplied", "夜间模式预设已应用"), + } + except Exception as e: + raise Exception(f"应用夜间模式预设失败: {str(e)}") + + @staticmethod + async def save_current_settings_backup(): + """保存当前设置作为备份 / Save current settings as backup""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + try: + backup_data = { + "timestamp": datetime.now().isoformat(), + "settings": camera.get_camera_info(), + } + + backup_file = DEBUG_CAPTURES_DIR / "settings_backup.json" + with open(backup_file, "w", encoding="utf-8") as f: + json.dump(backup_data, f, indent=2, ensure_ascii=False) + + return { + "success": True, + "backup_file": str(backup_file), + **i18n_payload("server.settingsBackedUp", "当前设置已备份"), + } + except Exception as e: + raise Exception(f"保存设置备份失败: {str(e)}") + + @staticmethod + async def restore_settings_backup(): + """从备份恢复设置 / Restore settings from backup""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + try: + backup_file = DEBUG_CAPTURES_DIR / "settings_backup.json" + if not backup_file.exists(): + raise Exception("未找到设置备份文件") + + with open(backup_file, encoding="utf-8") as f: + backup_data = json.load(f) + + settings = backup_data.get("settings", {}) + + # 恢复设置 / Restore settings + if "exposure_us" in settings: + camera.set_exposure(settings["exposure_us"]) + if "analogue_gain" in settings and "digital_gain" in settings: + camera.set_gain(settings["analogue_gain"], settings["digital_gain"]) + if "noise_reduction" in settings: + camera.set_noise_reduction(settings["noise_reduction"]) + if "white_balance_mode" in settings: + camera.set_white_balance(settings["white_balance_mode"]) + if "contrast" in settings and "brightness" in settings: + camera.set_image_enhancement( + settings.get("contrast", 1.0), + settings.get("brightness", 0.0), + settings.get("saturation", 1.0), + settings.get("sharpness", 1.0), + ) + if "night_mode" in settings: + camera.set_night_mode(settings["night_mode"]) + + return { + "success": True, + **i18n_payload("server.settingsRestored", "设置已从备份恢复"), + } + except Exception as e: + raise Exception(f"恢复设置备份失败: {str(e)}") + + @staticmethod + async def set_color_mode(color_mode: str): + """设置颜色模式 / Set color mode""" + camera = get_camera_instance() + if not camera or not camera.is_initialized: + raise Exception("相机未初始化") + + if color_mode not in ["color", "mono"]: + raise Exception("不支持的颜色模式,只支持 'color' 或 'mono'") + + try: + if hasattr(camera, "set_color_mode"): + success = await get_camera_manager().reconfigure_camera( + "set_color_mode", + lambda: camera.set_color_mode(color_mode), + timeout_sec=10.0, + ) + if success: + get_camera_manager().update_runtime_overrides( + {"color_mode": color_mode} + ) + mode_name = "彩色" if color_mode == "color" else "黑白" + return { + "success": True, + **i18n_payload( + "server.colorModeSwitched", + f"颜色模式已切换为{mode_name}模式", + {"mode": mode_name}, + ), + "color_mode": color_mode, + } + else: + raise Exception("相机不支持颜色模式切换") + else: + raise Exception("相机驱动不支持颜色模式切换") + except Exception as e: + raise Exception(f"设置颜色模式失败: {str(e)}") + class DebugPresetService: - """调试预设服务""" - + """调试预设服务 / Debug default service""" + @staticmethod async def get_presets(): - """获取相机预设列表""" + """获取相机预设列表 / Get a list of camera presets""" presets_file = DEBUG_CAPTURES_DIR / "presets.json" - + if not presets_file.exists(): return {"presets": []} - + try: - with open(presets_file, 'r', encoding='utf-8') as f: + with open(presets_file, encoding="utf-8") as f: data = json.load(f) return {"presets": data.get("presets", [])} except Exception as e: raise Exception(f"读取预设失败: {str(e)}") - + @staticmethod - async def save_preset(preset_data: Dict[str, Any]): - """保存相机预设""" + async def save_preset(preset_data: dict[str, Any]): + """保存相机预设 / Save camera presets""" presets_file = DEBUG_CAPTURES_DIR / "presets.json" - - # 读取现有预设 + + # 读取现有预设 / Read existing preset presets = [] if presets_file.exists(): try: - with open(presets_file, 'r', encoding='utf-8') as f: + with open(presets_file, encoding="utf-8") as f: data = json.load(f) presets = data.get("presets", []) - except: + except Exception: presets = [] - - # 检查是否已存在同名预设 + + # 检查是否已存在同名预设 / Check if a preset with the same name already exists for i, existing_preset in enumerate(presets): if existing_preset["name"] == preset_data["name"]: presets[i] = preset_data break else: - # 检查预设数量限制 + # 检查预设数量限制 / Check preset quantity limits if len(presets) >= 10: raise Exception("预设数量已达上限(10个)") presets.append(preset_data) - - # 保存预设 + + # 保存预设 / save preset try: - with open(presets_file, 'w', encoding='utf-8') as f: + with open(presets_file, "w", encoding="utf-8") as f: json.dump({"presets": presets}, f, indent=2, ensure_ascii=False) - - return {"success": True, "message": "预设保存成功"} + + return { + "success": True, + **i18n_payload("server.presetSaved", "预设保存成功"), + } except Exception as e: raise Exception(f"保存预设失败: {str(e)}") - + @staticmethod async def apply_preset(preset_name: str): - """应用相机预设""" + """应用相机预设 / Apply camera presets""" presets_file = DEBUG_CAPTURES_DIR / "presets.json" - + if not presets_file.exists(): raise Exception("预设文件不存在") - + try: - with open(presets_file, 'r', encoding='utf-8') as f: + with open(presets_file, encoding="utf-8") as f: data = json.load(f) presets = data.get("presets", []) - - # 查找预设 + + # 查找预设 / Find a preset preset = None for p in presets: if p["name"] == preset_name: preset = p break - + if not preset: raise Exception("预设不存在") - - # 应用预设到相机 + + # 应用预设到相机 / Apply preset to camera camera = get_camera_instance() if camera and camera.is_initialized: - camera.set_exposure(preset["exposure_us"]) - camera.set_gain(preset["analogue_gain"], preset["digital_gain"]) - - return {"success": True, "message": f"预设 '{preset_name}' 已应用"} - + # 自动曝光优先,避免手动参数与AE冲突 / Automatic exposure priority to avoid conflicts between manual parameters and AE + auto_exposure = preset.get("auto_exposure", False) + if hasattr(camera, "set_auto_exposure"): + camera.set_auto_exposure(auto_exposure) + + # 基础参数 / Basic parameters + if not auto_exposure: + camera.set_exposure(preset["exposure_us"]) + camera.set_gain( + preset["analogue_gain"], preset.get("digital_gain", 1.0) + ) + + # 图像增强参数 / Image enhancement parameters + if any( + key in preset + for key in ["contrast", "brightness", "saturation", "sharpness"] + ): + contrast = preset.get("contrast", 1.0) + brightness = preset.get("brightness", 0.0) + saturation = preset.get("saturation", 1.0) + sharpness = preset.get("sharpness", 1.0) + + if hasattr(camera, "set_image_enhancement"): + camera.set_image_enhancement( + contrast, brightness, saturation, sharpness + ) + + # 高级参数 / Advanced parameters + if "noise_reduction" in preset: + if hasattr(camera, "set_noise_reduction"): + camera.set_noise_reduction(preset["noise_reduction"]) + + # 白平衡设置 / White balance settings + if "white_balance_mode" in preset: + mode = preset["white_balance_mode"] + gain_r = preset.get("white_balance_gain_r", 1.0) + gain_b = preset.get("white_balance_gain_b", 1.0) + + if hasattr(camera, "set_white_balance"): + camera.set_white_balance(mode, gain_r, gain_b) + + # 旋转角度 / rotation angle + if "rotation" in preset: + if hasattr(camera, "set_rotation"): + camera.set_rotation(preset["rotation"]) + + # 颜色模式 / color mode + if "color_mode" in preset: + if hasattr(camera, "set_color_mode"): + camera.set_color_mode(preset["color_mode"]) + + return { + "success": True, + "preset": preset, + **i18n_payload( + "server.presetApplied", + f"预设 '{preset_name}' 已应用", + {"name": preset_name}, + ), + } + except Exception as e: raise Exception(f"应用预设失败: {str(e)}") - + @staticmethod async def delete_preset(preset_name: str): - """删除相机预设""" + """删除相机预设 / Delete camera preset""" presets_file = DEBUG_CAPTURES_DIR / "presets.json" - + if not presets_file.exists(): raise Exception("预设文件不存在") - + try: - with open(presets_file, 'r', encoding='utf-8') as f: + with open(presets_file, encoding="utf-8") as f: data = json.load(f) presets = data.get("presets", []) - - # 删除预设 + + # 删除预设 / Delete preset original_count = len(presets) presets = [p for p in presets if p["name"] != preset_name] - + if len(presets) == original_count: raise Exception("预设不存在") - - # 保存更新后的预设 - with open(presets_file, 'w', encoding='utf-8') as f: + + # 保存更新后的预设 / Save updated preset + with open(presets_file, "w", encoding="utf-8") as f: json.dump({"presets": presets}, f, indent=2, ensure_ascii=False) - - return {"success": True, "message": f"预设 '{preset_name}' 已删除"} - + + return { + "success": True, + **i18n_payload( + "server.presetDeleted", + f"预设 '{preset_name}' 已删除", + {"name": preset_name}, + ), + } + except Exception as e: raise Exception(f"删除预设失败: {str(e)}") class DebugFileService: - """调试文件服务""" - + """调试文件服务 / Debug file service""" + @staticmethod async def get_files(): - """获取拍摄文件列表""" + """获取拍摄文件列表 / Get shooting file list""" try: + # 支持的图片格式 / Supported image formats + image_extensions = { + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".tiff", + ".tif", + ".webp", + } + # 支持的视频格式 / Supported video formats + video_extensions = { + ".mp4", + ".avi", + ".mov", + ".mkv", + ".wmv", + ".flv", + ".webm", + ".m4v", + } + files = [] for file_path in DEBUG_CAPTURES_DIR.iterdir(): - if file_path.is_file() and file_path.suffix.lower() in ['.jpg', '.mp4']: - files.append({ - "name": file_path.name, - "size": file_path.stat().st_size, - "modified": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat(), - "type": "image" if file_path.suffix.lower() == '.jpg' else "video" - }) - - # 按修改时间排序(最新的在前) + if file_path.is_file(): + suffix = file_path.suffix.lower() + if suffix in image_extensions or suffix in video_extensions: + files.append( + { + "name": file_path.name, + "size": file_path.stat().st_size, + "modified": datetime.fromtimestamp( + file_path.stat().st_mtime + ).isoformat(), + "type": ( + "image" if suffix in image_extensions else "video" + ), + } + ) + + # 按修改时间排序(最新的在前) / Sort by modification time (newest first) files.sort(key=lambda x: x["modified"], reverse=True) - + return {"files": files} - + except Exception as e: raise Exception(f"获取文件列表失败: {str(e)}") - + @staticmethod async def get_file_info(filename: str): - """获取文件信息""" + """获取文件信息 / Get file information""" file_path = DEBUG_CAPTURES_DIR / filename info_path = DEBUG_CAPTURES_DIR / f"{file_path.stem}.txt" - + if not file_path.exists(): raise Exception("文件不存在") - + try: + # 支持的图片格式 / Supported image formats + image_extensions = { + ".jpg", + ".jpeg", + ".png", + ".bmp", + ".tiff", + ".tif", + ".webp", + } + # 支持的视频格式 / Supported video formats + + suffix = file_path.suffix.lower() + file_type = "image" if suffix in image_extensions else "video" + info = { "filename": filename, "size": file_path.stat().st_size, - "modified": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat(), - "type": "image" if file_path.suffix.lower() == '.jpg' else "video" + "modified": datetime.fromtimestamp( + file_path.stat().st_mtime + ).isoformat(), + "type": file_type, } - - # 读取拍摄信息 + + # 读取拍摄信息;将 camera 内字段展开到顶层以兼容前端详情 / Read sidecar; flatten camera for UI if info_path.exists(): - with open(info_path, 'r', encoding='utf-8') as f: + with open(info_path, encoding="utf-8") as f: capture_info = json.load(f) + if isinstance(capture_info, dict): + cam = capture_info.get("camera") + if isinstance(cam, dict): + for k in ( + "exposure_us", + "analogue_gain", + "digital_gain", + "fps", + "auto_exposure", + "rotation", + "sampling_mode", + "color_mode", + "sensor", + "resolution", + ): + if k not in capture_info and k in cam: + capture_info[k] = cam[k] + if capture_info.get("resolution") is None: + ow = cam.get("output_width") or cam.get("width") + oh = cam.get("output_height") or cam.get("height") + if ow and oh: + capture_info["resolution"] = f"{ow}x{oh}" + extra = capture_info.get("extra") + if isinstance(extra, dict): + for k, v in extra.items(): + if k not in capture_info: + capture_info[k] = v info.update(capture_info) - + return info - + except Exception as e: raise Exception(f"获取文件信息失败: {str(e)}") + + @staticmethod + async def delete_file(filename: str): + """删除文件 / Delete files""" + try: + file_path = DEBUG_CAPTURES_DIR / filename + info_path = DEBUG_CAPTURES_DIR / f"{file_path.stem}.txt" + + if not file_path.exists(): + raise Exception("文件不存在") + + # 删除主文件 / Delete master file + file_path.unlink() + + # 删除对应的参数文件(如果存在) / Delete the corresponding parameter file (if it exists) + if info_path.exists(): + info_path.unlink() + + return i18n_payload( + "server.fileDeleted", + f"文件 {filename} 删除成功", + {"filename": filename}, + ) + + except Exception as e: + raise Exception(f"删除文件失败: {str(e)}") diff --git a/ogscope/web/api/main.py b/ogscope/web/api/main.py index 4a3d501..b0abaa3 100644 --- a/ogscope/web/api/main.py +++ b/ogscope/web/api/main.py @@ -2,40 +2,23 @@ OGScope Web API 主路由 整合所有API模块 """ + from fastapi import APIRouter -from ogscope.web.api.camera.routes import router as camera_router + from ogscope.web.api.alignment.routes import router as alignment_router -from ogscope.web.api.system.routes import router as system_router +from ogscope.web.api.analysis.routes import router as analysis_router +from ogscope.web.api.camera.routes import router as camera_router from ogscope.web.api.debug.routes import router as debug_router +from ogscope.web.api.network.routes import router as network_router +from ogscope.web.api.system.routes import router as system_router -# 创建主路由器 +# 创建主路由器 / Create the main router router = APIRouter() -# 注册各个模块的路由 -router.include_router(camera_router) -router.include_router(alignment_router) -router.include_router(system_router) -router.include_router(debug_router) - - -@router.get("/api") -async def api_root(): - """API根路径""" - return { - "name": "OGScope API", - "version": "1.0.0", - "status": "running", - "docs": "/docs", - "modules": { - "camera": "相机控制API", - "alignment": "极轴校准API", - "system": "系统信息API", - "debug": "调试控制台API" - }, - "endpoints": { - "camera": "/api/camera/", - "alignment": "/api/alignment/", - "system": "/api/system/", - "debug": "/api/debug/" - } - } +# 注册各个模块的路由(含分组标签)/ Register routes for each module (with group tags) +router.include_router(camera_router, tags=["Camera - 相机"]) +router.include_router(alignment_router, tags=["Alignment - 极轴校准"]) +router.include_router(system_router, tags=["System - 系统"]) +router.include_router(network_router, tags=["Network - 网络"]) +router.include_router(debug_router, tags=["Debug - 调试"]) +router.include_router(analysis_router, tags=["Analysis - 分析"]) diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index b6ffe72..0bbc21e 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -1,26 +1,33 @@ """ API 数据模型定义 """ -from pydantic import BaseModel -from typing import Optional, Dict, Any +from typing import Any, Literal, Optional + +from pydantic import BaseModel, ConfigDict, Field, field_validator -class CameraSettings(BaseModel): - """相机设置""" - exposure: int # 曝光时间 (微秒) - gain: float # 增益 +class CameraSettings(BaseModel): + """相机设置 / camera settings""" -class PolarAlignStatus(BaseModel): - """极轴校准状态""" - is_running: bool - progress: float # 0-100 - azimuth_error: float # 方位误差 (角分) - altitude_error: float # 高度误差 (角分) + exposure: int # 曝光时间 (微秒) / Exposure time (microseconds) + gain: float # 增益 / Gain + autoExposure: Optional[bool] = True # 自动曝光开关 / automatic exposure switch + digitalGain: Optional[float] = 1.0 # 数字增益 / digital gain + contrast: Optional[float] = 1.0 # 对比度 / Contrast + brightness: Optional[float] = 0.0 # 亮度 / brightness + saturation: Optional[float] = 1.0 # 饱和度 / saturation + sharpness: Optional[float] = 1.0 # 锐度 / sharpness + noiseReduction: Optional[int] = 0 # 降噪级别 (0-4) / Noise reduction level (0-4) + whiteBalanceMode: Optional[str] = "auto" # 白平衡模式 / white balance mode + whiteBalanceGainR: Optional[float] = 1.0 # 白平衡红色增益 / white balance red gain + whiteBalanceGainB: Optional[float] = 1.0 # 白平衡蓝色增益 / white balance blue gain + colorMode: Optional[str] = "color" # 颜色模式: 'color' | 'mono' class CameraPreset(BaseModel): - """相机预设""" + """相机预设 / camera presets""" + name: str description: str = "" exposure_us: int @@ -28,10 +35,24 @@ class CameraPreset(BaseModel): digital_gain: float = 1.0 auto_exposure: bool = False auto_gain: bool = False + # 图像增强参数 / Image enhancement parameters + contrast: Optional[float] = 1.0 + brightness: Optional[float] = 0.0 + saturation: Optional[float] = 1.0 + sharpness: Optional[float] = 1.0 + # 高级参数 / Advanced parameters + noise_reduction: Optional[int] = 0 + white_balance_mode: Optional[str] = "auto" + white_balance_gain_r: Optional[float] = 1.0 + white_balance_gain_b: Optional[float] = 1.0 + # 其他参数 / Other parameters + rotation: Optional[int] = 180 + color_mode: Optional[str] = "color" # 颜色模式: 'color' | 'mono' class CaptureInfo(BaseModel): - """拍摄信息""" + """拍摄信息 / Shooting information""" + filename: str timestamp: str exposure_us: int @@ -42,18 +63,303 @@ class CaptureInfo(BaseModel): class SystemInfo(BaseModel): - """系统信息""" + """系统信息 / System information""" + platform: str os: str cpu_usage: float memory_usage: float temperature: float + wifi_quality: Optional[float] = None + wifi_signal_dbm: Optional[float] = None + wifi_interface: Optional[str] = None + uptime_seconds: int = 0 + load_average_1m: float = 0.0 + + +class WifiStatus(BaseModel): + """WiFi 模式状态 / WiFi mode status (STA vs AP).""" + + mode: Literal["ap", "sta", "unknown"] + active_connection: Optional[str] = None + wireless_interface: str = "wlan0" + sta_connection: str = "" + ap_connection: str = "" + ap_ipv4: Optional[str] = None + ap_url_hint: Optional[str] = None + configured: bool = True + message: Optional[str] = None + device_id_suffix: Optional[str] = None + ap_ssid: Optional[str] = None + mdns_hostname_hint: Optional[str] = None + + +class WifiModeRequest(BaseModel): + """切换 WiFi 模式 / Switch WiFi mode.""" + + mode: Literal["ap", "sta"] + + +class WifiNetworkScanEntry(BaseModel): + """扫描到的 WiFi / One scanned WiFi network.""" + + ssid: str + signal: Optional[int] = None + security: Optional[str] = None + + +class WifiScanResponse(BaseModel): + """WiFi 扫描结果 / WiFi scan response.""" + + networks: list[WifiNetworkScanEntry] + hint: Optional[str] = Field( + default=None, + description="空列表或降级时的说明(如 AP 模式无法扫描)/ UX hint when empty or degraded", + ) + + +class WifiProfileEntry(BaseModel): + """已保存的 WiFi 连接 / Saved WiFi connection profile.""" + + connection_name: str + ssid: str + autoconnect: bool + + +class WifiProfilesResponse(BaseModel): + """已保存配置列表 / Saved profiles list.""" + + profiles: list[WifiProfileEntry] + + +class WifiStaConnectRequest(BaseModel): + """连接外部 WiFi(切 STA)/ Connect to external WiFi (STA mode).""" + + ssid: str + password: Optional[str] = None + + +class WifiProfileActivateRequest(BaseModel): + """激活已保存连接 / Activate saved connection.""" + + connection_name: str class AlignmentStatus(BaseModel): - """校准状态""" + """校准状态 / calibration status""" + status: str azimuth_error: float altitude_error: float precision: str progress: int + + +class CentroidParamsPayload(BaseModel): + """Tetra3 提星参数覆盖(未填则用环境默认)/ Optional centroid extraction overrides.""" + + model_config = ConfigDict(extra="forbid") + + sigma: Optional[float] = None + max_area: Optional[int] = None + min_area: Optional[int] = None + filtsize: Optional[int] = None + binary_open: Optional[bool] = None + bg_sub_mode: Optional[str] = None + sigma_mode: Optional[str] = None + max_axis_ratio: Optional[float] = None + + @field_validator("filtsize") + @classmethod + def filtsize_must_be_odd(cls, v: Optional[int]) -> Optional[int]: + """滤波边长须为奇数 / Filter size must be odd (Tetra3).""" + if v is None: + return None + if v < 1: + raise ValueError("filtsize must be >= 1") + if v % 2 == 0: + raise ValueError("filtsize must be odd") + return v + + +class AnalysisSolveImageRequest(BaseModel): + """单图解算请求(JSON body)/ Single-image plate solve request.""" + + model_config = ConfigDict(extra="forbid") + + input_name: str + hint_ra_deg: Optional[float] = None + hint_dec_deg: Optional[float] = None + fov_estimate: Optional[float] = None + fov_max_error: Optional[float] = None + solve_timeout_ms: Optional[int] = None + solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None + centroid: Optional[CentroidParamsPayload] = None + max_image_side: Optional[int] = None + large_scale_bg_subtract: Optional[bool] = False + # 结果详细程度:summary 仅返回关键字段,full 包含 tetra 原始块 / Result detail level + detail_level: Optional[Literal["summary", "full"]] = "summary" + + +class AnalysisExtractPreviewRequest(BaseModel): + """提星掩膜预览请求 / Centroid extraction preview (binary mask).""" + + model_config = ConfigDict(extra="forbid") + + input_name: str + centroid: Optional[CentroidParamsPayload] = None + max_image_side: Optional[int] = None + large_scale_bg_subtract: Optional[bool] = False + + +class AnalysisJobCreateRequest(BaseModel): + """分析任务创建请求 / Analysis job create request""" + + input_name: str + input_type: str # image | video + hint_ra_deg: Optional[float] = None + hint_dec_deg: Optional[float] = None + frame_step: int = 1 + max_frames: int = 180 + fov_estimate: Optional[float] = None + fov_max_error: Optional[float] = None + solve_timeout_ms: Optional[int] = None + centroid: Optional[CentroidParamsPayload] = None + max_image_side: Optional[int] = None + large_scale_bg_subtract: Optional[bool] = False + + +class SolveFrameResult(BaseModel): + """单帧解算结果 / Single frame solving result""" + + frame_index: int + ra_deg: float + dec_deg: float + solve_source: str + status: str = "" + + +class AnalysisJobStatusResponse(BaseModel): + """分析任务状态响应 / Analysis job status response""" + + job_id: str + status: str + progress: float + message: str = "" + result_path: Optional[str] = None + + +class AnalysisSolveParamsOnly(BaseModel): + """解算参数(不含文件名,用于预设与批量)/ Solve params without input filename.""" + + model_config = ConfigDict(extra="forbid") + + hint_ra_deg: Optional[float] = None + hint_dec_deg: Optional[float] = None + fov_estimate: Optional[float] = None + fov_max_error: Optional[float] = None + solve_timeout_ms: Optional[int] = None + solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None + centroid: Optional[CentroidParamsPayload] = None + max_image_side: Optional[int] = None + large_scale_bg_subtract: Optional[bool] = False + detail_level: Optional[Literal["summary", "full"]] = "summary" + + +class BatchSolveRunItem(BaseModel): + """批量解算单轮 / One batch solve run.""" + + label: str + params: AnalysisSolveParamsOnly + + +class AnalysisBatchSolveRequest(BaseModel): + """批量解算请求 / Batch plate solve request.""" + + model_config = ConfigDict(extra="forbid") + + input_name: str + runs: list[BatchSolveRunItem] + + +class AnalysisPresetCreate(BaseModel): + """用户预设创建 / User preset create.""" + + model_config = ConfigDict(extra="forbid") + + name: str + params: AnalysisSolveParamsOnly + + +class AnalysisExperimentCreate(BaseModel): + """实验记录保存 / Save experiment record.""" + + model_config = ConfigDict(extra="forbid") + + input_name: str + preset_label: str + result_json: dict[str, Any] + metrics: dict[str, Any] = Field(default_factory=dict) + thumbnail_png_base64: Optional[str] = None + replay: Optional[dict[str, Any]] = None + save_asset_snapshot: bool = True + + +class AnalysisSolveVideoFrameRequest(BaseModel): + """单帧解算:相机 BGR 或素材池视频 seek / Solve one frame from camera or pool video.""" + + model_config = ConfigDict(extra="forbid") + + # 基本输入来源 / Basic input source + source: Literal["camera", "file"] + input_name: Optional[str] = None + frame_index: int = 0 + time_sec: Optional[float] = None + solve_interval_ms: Optional[int] = Field( + default=None, + ge=200, + le=60000, + description="期望解算间隔(毫秒);后端会按系统上下限裁剪 / Desired solve interval in ms (server-clamped)", + ) + # 解算参数 / Solve parameters + hint_ra_deg: Optional[float] = None + hint_dec_deg: Optional[float] = None + fov_estimate: Optional[float] = None + fov_max_error: Optional[float] = None + solve_timeout_ms: Optional[int] = None + solve_profile: Optional[Literal["speed", "balanced", "robust"]] = None + centroid: Optional[CentroidParamsPayload] = None + max_image_side: Optional[int] = None + large_scale_bg_subtract: Optional[bool] = False + detail_level: Optional[Literal["summary", "full"]] = "summary" + + # 叠加与引导选项(可选,未提供则使用后端默认)/ Optional overlay & guidance options + overlay_topn_count: Optional[int] = Field( + default=None, + description="自动标注的星点数量上限(Top-N),未填用服务器默认 / Max number of stars to label (Top-N); server default if omitted", + ) + enable_polar_guide: Optional[bool] = Field( + default=None, + description="是否计算极轴引导信息;未填用服务器默认 / Whether to compute polar guide info; server default if omitted", + ) + + +class ImportFromDebugRequest(BaseModel): + """从调试采集目录导入到分析素材池 / Import capture into analysis pool.""" + + model_config = ConfigDict(extra="forbid") + + filename: str + + +class AnalysisReplaceVideoRequest(BaseModel): + """转码后替换素材视频 / Replace original video after client transcode.""" + + model_config = ConfigDict(extra="forbid") + + old_filename: str + new_filename: str + duration_s: Optional[float] = None + nominal_fps: Optional[float] = None + codec_fourcc: Optional[str] = None + container: Optional[str] = None diff --git a/ogscope/web/api/network/__init__.py b/ogscope/web/api/network/__init__.py new file mode 100644 index 0000000..92b242c --- /dev/null +++ b/ogscope/web/api/network/__init__.py @@ -0,0 +1,3 @@ +""" +网络 API 模块 / Network API module. +""" diff --git a/ogscope/web/api/network/routes.py b/ogscope/web/api/network/routes.py new file mode 100644 index 0000000..e71a180 --- /dev/null +++ b/ogscope/web/api/network/routes.py @@ -0,0 +1,181 @@ +""" +网络相关 API 路由(WiFi AP/STA) / Network API routes for WiFi AP/STA. +""" + +from __future__ import annotations + +import asyncio +import subprocess + +from fastapi import APIRouter, HTTPException + +from ogscope.config import get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service +from ogscope.web.api.models.schemas import ( + WifiModeRequest, + WifiNetworkScanEntry, + WifiProfileActivateRequest, + WifiProfileEntry, + WifiProfilesResponse, + WifiScanResponse, + WifiStaConnectRequest, + WifiStatus, +) +from ogscope.web.api.network import services as net_services + +router = APIRouter() + + +def _build_wifi_status() -> WifiStatus: + settings = get_settings() + configured = wifi_switch_service.is_configured() + data = wifi_switch_service.get_status() + mode = data.get("MODE", "unknown") + active_connection = data.get("ACTIVE_CONNECTION") or None + wireless_interface = data.get("WIRELESS_INTERFACE", settings.wifi_interface) + sta_connection = data.get("STA_CONNECTION", settings.wifi_sta_connection) + ap_connection = data.get("AP_CONNECTION", settings.wifi_ap_connection) + ap_ipv4 = data.get("AP_IPV4") or None + ap_url_hint = ( + f"http://{settings.wifi_ap_url_host}:{settings.port}" if mode == "ap" else None + ) + message = data.get("error") + suffix = settings.device_id_suffix or None + ap_ssid = settings.wifi_ap_ssid or None + mdns_hint = f"ogscope-{suffix}.local" if suffix else None + return WifiStatus( + mode=mode if mode in {"ap", "sta"} else "unknown", + active_connection=active_connection, + wireless_interface=wireless_interface, + sta_connection=sta_connection, + ap_connection=ap_connection, + ap_ipv4=ap_ipv4, + ap_url_hint=ap_url_hint, + configured=configured, + message=message, + device_id_suffix=suffix, + ap_ssid=ap_ssid, + mdns_hostname_hint=mdns_hint, + ) + + +@router.get("/network/wifi", response_model=WifiStatus) +async def get_wifi_status() -> WifiStatus: + """获取 WiFi 模式状态 / Get WiFi mode status.""" + return _build_wifi_status() + + +@router.post("/network/wifi", response_model=WifiStatus) +async def switch_wifi_mode(payload: WifiModeRequest) -> WifiStatus: + """切换 WiFi 模式(AP/STA)/ Switch WiFi mode.""" + if payload.mode == "ap": + net_services.cancel_sta_rollback_watch() + try: + wifi_switch_service.switch(payload.mode) + if payload.mode == "sta": + net_services.schedule_sta_rollback_watch() + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except subprocess.TimeoutExpired as e: + raise HTTPException(status_code=504, detail=f"wifi_switch_timeout: {e}") from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"wifi_switch_failed: {err}") from e + return _build_wifi_status() + + +@router.get("/network/wifi/scan", response_model=WifiScanResponse) +async def scan_wifi() -> WifiScanResponse: + """扫描附近 WiFi(由设备执行 nmcli)/ Scan WiFi (nmcli on device).""" + settings = get_settings() + try: + nets, scan_hint = await asyncio.to_thread( + net_services.nmcli_wifi_scan, + settings.wifi_interface, + ) + except subprocess.TimeoutExpired as e: + raise HTTPException(status_code=504, detail=f"wifi_scan_timeout: {e}") from e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + return WifiScanResponse( + networks=[WifiNetworkScanEntry(**n) for n in nets], + hint=scan_hint, + ) + + +@router.get("/network/wifi/profiles", response_model=WifiProfilesResponse) +async def list_wifi_profiles() -> WifiProfilesResponse: + """列出已保存的 WiFi 连接(不含 AP)/ List saved WiFi profiles.""" + settings = get_settings() + try: + rows = await asyncio.to_thread( + net_services.nmcli_wifi_profiles, + settings, + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + return WifiProfilesResponse( + profiles=[ + WifiProfileEntry( + connection_name=r["connection_name"], + ssid=r["ssid"], + autoconnect=r["autoconnect"], + ) + for r in rows + ], + ) + + +@router.post("/network/wifi/sta/connect", response_model=WifiStatus) +async def connect_sta_wifi(payload: WifiStaConnectRequest) -> WifiStatus: + """配置 STA 并切换到 STA,启动失败回滚监视 / Configure STA and switch, start rollback watch.""" + settings = get_settings() + if not wifi_switch_service.is_configured(): + raise HTTPException(status_code=503, detail="wifi_not_configured") + try: + await asyncio.to_thread( + net_services.nmcli_modify_sta_to_ssid, + settings, + payload.ssid, + payload.password, + ) + await asyncio.to_thread(wifi_switch_service.switch, "sta") + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"sta_connect_failed: {err}") from e + net_services.schedule_sta_rollback_watch() + return _build_wifi_status() + + +@router.post("/network/wifi/profile/activate", response_model=WifiStatus) +async def activate_wifi_profile(payload: WifiProfileActivateRequest) -> WifiStatus: + """激活已保存连接并切 STA / Activate saved profile (STA).""" + settings = get_settings() + if not wifi_switch_service.is_configured(): + raise HTTPException(status_code=503, detail="wifi_not_configured") + name = payload.connection_name.strip() + if not name: + raise HTTPException(status_code=400, detail="empty_connection_name") + try: + if name == settings.wifi_sta_connection: + await asyncio.to_thread(wifi_switch_service.switch, "sta") + else: + await asyncio.to_thread( + net_services.nm_down_if_exists, settings.wifi_ap_connection + ) + await asyncio.to_thread( + net_services.nmcli_activate_connection, + settings, + name, + ) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) from e + except subprocess.CalledProcessError as e: + err = (e.stderr or e.output or str(e)).strip() + raise HTTPException(status_code=500, detail=f"activate_failed: {err}") from e + net_services.schedule_sta_rollback_watch() + return _build_wifi_status() diff --git a/ogscope/web/api/network/services.py b/ogscope/web/api/network/services.py new file mode 100644 index 0000000..7b8fcd8 --- /dev/null +++ b/ogscope/web/api/network/services.py @@ -0,0 +1,405 @@ +""" +NetworkManager(nmcli)操作与 STA 回滚监视 / nmcli helpers and STA rollback watcher. +""" + +from __future__ import annotations + +import asyncio +import os +import re +import shutil +import subprocess +import threading +import time +from typing import Any + +from loguru import logger + +from ogscope.config import Settings, get_settings +from ogscope.hardware.wifi_switch import wifi_switch_service + +_nm_lock = threading.Lock() + + +def _nm_executable() -> str: + """nmcli 绝对路径或回退名 / Resolved nmcli path.""" + return shutil.which("nmcli") or "nmcli" + + +def _run_nm(args: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[str]: + """执行 nmcli;非 root 时默认 sudo -n,避免 polkit Not authorized / Run nmcli; sudo when needed.""" + s = get_settings() + if s.wifi_nmcli_use_sudo and os.geteuid() != 0: + cmd = ["sudo", "-n", _nm_executable(), *args] + else: + cmd = [_nm_executable(), *args] + with _nm_lock: + return subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def nmcli_rescan(iface: str) -> None: + """触发 WiFi 重扫 / Trigger WiFi rescan.""" + try: + _run_nm(["device", "wifi", "rescan", "ifname", iface], timeout=30) + except (subprocess.TimeoutExpired, OSError) as e: + logger.warning("nmcli rescan 失败 / rescan failed: {}", e) + + +def _parse_nmcli_wifi_tab(stdout: str) -> list[dict[str, Any]]: + networks: list[dict[str, Any]] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line or line.lower().startswith("ssid"): + continue + parts = line.split("\t") + if len(parts) < 2: + continue + ssid = parts[0].strip() + if not ssid or ssid == "--": + continue + sig: int | None = None + try: + if len(parts) > 1 and parts[1].strip(): + sig = int(parts[1].strip()) + except ValueError: + pass + sec = parts[2].strip() if len(parts) > 2 else "" + networks.append( + {"ssid": ssid, "signal": sig, "security": sec or None}, + ) + return networks + + +def _parse_nmcli_wifi_colon(stdout: str) -> list[dict[str, Any]]: + """-t 模式用冒号分隔;SSID 若含冒号则从右侧取两段 / Colon mode; rsplit for SSID with colons.""" + networks: list[dict[str, Any]] = [] + for line in stdout.splitlines(): + line = line.strip() + if not line: + continue + parts = line.rsplit(":", 2) + if len(parts) < 3: + continue + ssid, sig_s, sec = parts[0].strip(), parts[1].strip(), parts[2].strip() + if not ssid or ssid == "--": + continue + sig: int | None = None + try: + sig = int(sig_s) + except ValueError: + pass + networks.append({"ssid": ssid, "signal": sig, "security": sec or None}) + return networks + + +def _device_active_connection_name(iface: str) -> str: + """当前接口活动连接名 / Active connection name on iface.""" + proc = _run_nm( + ["-t", "-f", "GENERAL.CONNECTION", "device", "show", iface], timeout=15 + ) + if proc.returncode != 0: + return "" + line = (proc.stdout or "").strip().split("\n", 1)[0].strip() + return line if line and line != "--" else "" + + +def _merge_networks_by_ssid(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """同名 SSID 保留信号最强的一条 / Keep strongest signal per SSID.""" + best: dict[str, dict[str, Any]] = {} + for n in rows: + ssid = n.get("ssid") or "" + if not ssid: + continue + prev = best.get(ssid) + if prev is None: + best[ssid] = n + continue + s_new = n.get("signal") + s_old = prev.get("signal") + if isinstance(s_new, int) and (not isinstance(s_old, int) or s_new > s_old): + best[ssid] = n + return list(best.values()) + + +def _wifi_list_one( + iface: str | None, + *, + prefer_tab: bool, +) -> tuple[list[dict[str, Any]], int, str]: + """单次 list;返回 (解析结果, returncode, stderr) / Single list parse.""" + base_tab = ["-m", "tab", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"] + base_t = ["-t", "-f", "SSID,SIGNAL,SECURITY", "device", "wifi", "list"] + if iface: + args_tab = [*base_tab, "ifname", iface] + args_t = [*base_t, "ifname", iface] + else: + args_tab = base_tab + args_t = base_t + if prefer_tab: + proc = _run_nm(args_tab, timeout=60) + if proc.returncode == 0 and (proc.stdout or "").strip(): + return ( + _parse_nmcli_wifi_tab(proc.stdout or ""), + proc.returncode, + proc.stderr or "", + ) + proc2 = _run_nm(args_t, timeout=60) + if proc2.returncode == 0: + return ( + _parse_nmcli_wifi_colon(proc2.stdout or ""), + proc2.returncode, + proc2.stderr or "", + ) + logger.debug( + "nmcli wifi list(tab+t) iface={} rc={} / {}", + iface, + proc2.returncode, + proc2.stderr, + ) + return [], proc2.returncode, proc2.stderr or "" + proc = _run_nm(args_t, timeout=60) + if proc.returncode == 0: + return ( + _parse_nmcli_wifi_colon(proc.stdout or ""), + proc.returncode, + proc.stderr or "", + ) + return [], proc.returncode, proc.stderr or "" + + +def nmcli_wifi_scan(iface: str) -> tuple[list[dict[str, Any]], str | None]: + """扫描可见 AP;返回 (列表, 可选提示) / List APs; returns (list, optional hint).""" + settings = get_settings() + last_err = "" + networks: list[dict[str, Any]] = [] + ap_name = settings.wifi_ap_connection + active = _device_active_connection_name(iface) + ap_mode = bool(ap_name and active == ap_name) + + for attempt in range(1, 3): + nmcli_rescan(iface) + time.sleep(1.5 if attempt == 1 else 2.5) + for use_iface in (iface, None): + nets, rc, err = _wifi_list_one(use_iface, prefer_tab=True) + last_err = err or last_err + if nets: + networks = _merge_networks_by_ssid(nets) + networks.sort( + key=lambda x: (-(x.get("signal") or -1000), x.get("ssid") or "") + ) + return networks, None + if rc != 0 and err: + logger.warning( + "nmcli wifi list 失败 attempt={} iface={!r} / failed: {}", + attempt, + use_iface, + err, + ) + time.sleep(1.0) + + hint: str | None = None + if ap_mode: + hint = ( + "当前为热点(AP)模式:单频网卡通常无法同时列出周边 WiFi。" + "请用下方「手动输入 SSID」连接;或先切到 STA 再试扫描(视驱动而定)。" + ) + elif not networks: + hint = ( + "未扫描到网络。请稍后重试、检查天线/区域码," + "或查看日志中 nmcli 错误。" + + (f" 末次: {last_err.strip()[:200]}" if last_err.strip() else "") + ) + return networks, hint + + +def _connection_mode(name: str) -> str: + proc = _run_nm( + ["-g", "802-11-wireless.mode", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return "" + return (proc.stdout or "").strip().split("\n", 1)[0].strip() + + +def _connection_ssid(name: str) -> str: + proc = _run_nm( + ["-g", "802-11-wireless.ssid", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return "" + return (proc.stdout or "").strip().split("\n", 1)[0].strip() + + +def _connection_autoconnect(name: str) -> bool: + proc = _run_nm( + ["-g", "connection.autoconnect", "connection", "show", name], timeout=15 + ) + if proc.returncode != 0: + return False + v = (proc.stdout or "").strip().lower() + return v in {"yes", "true", "1"} + + +def nmcli_wifi_profiles(settings: Settings) -> list[dict[str, Any]]: + """列出已保存的 WiFi 连接(不含 AP)/ List saved WiFi connections (exclude AP).""" + proc = _run_nm(["-t", "-f", "NAME,TYPE", "connection", "show"], timeout=30) + if proc.returncode != 0: + return [] + profiles: list[dict[str, Any]] = [] + ap_name = settings.wifi_ap_connection + for line in (proc.stdout or "").splitlines(): + if ":" not in line: + continue + name, typ = line.split(":", 1) + name = name.strip() + typ = typ.strip() + if typ != "802-11-wireless": + continue + if name == ap_name: + continue + mode = _connection_mode(name) + if mode == "ap": + continue + ssid = _connection_ssid(name) + profiles.append( + { + "connection_name": name, + "ssid": ssid or name, + "autoconnect": _connection_autoconnect(name), + }, + ) + return profiles + + +def nmcli_modify_sta_to_ssid( + settings: Settings, ssid: str, password: str | None +) -> None: + """将 STA 连接改为指定 SSID 与密码 / Point STA profile at SSID (WPA or open).""" + sta = settings.wifi_sta_connection + if not sta: + raise RuntimeError("wifi_sta_connection not configured") + proc = _run_nm(["connection", "modify", sta, "wifi.ssid", ssid], timeout=30) + if proc.returncode != 0: + raise RuntimeError(proc.stderr or proc.stdout or "nmcli modify ssid failed") + if password: + proc2 = _run_nm( + [ + "connection", + "modify", + sta, + "wifi-sec.key-mgmt", + "wpa-psk", + "wifi-sec.psk", + password, + ], + timeout=30, + ) + if proc2.returncode != 0: + raise RuntimeError( + proc2.stderr or proc2.stdout or "nmcli modify psk failed" + ) + else: + proc3 = _run_nm( + ["connection", "modify", sta, "wifi-sec.key-mgmt", "none"], + timeout=30, + ) + if proc3.returncode != 0: + raise RuntimeError( + proc3.stderr or proc3.stdout or "nmcli modify open failed" + ) + + +def nm_down_if_exists(conn_name: str) -> None: + """尝试 down 连接(忽略未激活)/ Try to bring connection down.""" + proc = _run_nm(["connection", "down", conn_name], timeout=30) + if proc.returncode != 0: + logger.debug("connection down {}: {} / {}", conn_name, proc.stdout, proc.stderr) + + +def nmcli_activate_connection(settings: Settings, connection_name: str) -> None: + """激活指定连接(STA 用)/ Bring up a saved connection.""" + proc = _run_nm( + ["connection", "up", connection_name, "ifname", settings.wifi_interface], + timeout=settings.wifi_switch_timeout_seconds, + ) + if proc.returncode != 0: + raise RuntimeError(proc.stderr or proc.stdout or "nmcli connection up failed") + + +def sta_interface_has_usable_ipv4(settings: Settings) -> bool: + """STA 是否已获得非链路本地 IPv4 / Whether STA has non-link-local IPv4.""" + iface = settings.wifi_interface + proc = _run_nm(["-g", "IP4.ADDRESS", "device", "show", iface], timeout=15) + if proc.returncode != 0: + return False + text = (proc.stdout or "").strip() + if not text or text == "--": + return False + for line in text.split("\n"): + line = line.strip() + if not line: + continue + m = re.match(r"^([\d.]+)/", line) + if m and not m.group(1).startswith("169.254."): + return True + return False + + +_sta_rollback_task: asyncio.Task[None] | None = None + + +def cancel_sta_rollback_watch() -> None: + """取消 STA 回滚任务 / Cancel STA rollback task.""" + global _sta_rollback_task + if _sta_rollback_task and not _sta_rollback_task.done(): + _sta_rollback_task.cancel() + logger.info("已取消 STA 回滚监视 / STA rollback watchdog cancelled") + _sta_rollback_task = None + + +def schedule_sta_rollback_watch() -> None: + """STA 切换后启动回滚监视(超时切回 AP)/ Start rollback watchdog after STA switch.""" + global _sta_rollback_task + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + if _sta_rollback_task and not _sta_rollback_task.done(): + _sta_rollback_task.cancel() + _sta_rollback_task = loop.create_task(_sta_rollback_loop()) + + +async def _sta_rollback_loop() -> None: + settings = get_settings() + timeout_s = settings.wifi_sta_rollback_timeout_seconds + interval_s = settings.wifi_sta_rollback_interval_seconds + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + logger.info( + "STA 回滚监视启动,{}s 内无可用 IPv4 则切回 AP / STA rollback watchdog {}s", + timeout_s, + timeout_s, + ) + try: + while loop.time() < deadline: + await asyncio.sleep(interval_s) + ok = await asyncio.to_thread(sta_interface_has_usable_ipv4, settings) + if ok: + logger.info( + "STA 回滚监视:已获得 IPv4,停止 / STA watchdog: got IPv4, stop" + ) + return + logger.warning( + "STA 回滚监视:超时,切回 AP / STA watchdog: timeout, switching to AP" + ) + await asyncio.to_thread(wifi_switch_service.switch, "ap") + except asyncio.CancelledError: + logger.info("STA 回滚监视已取消 / STA rollback cancelled") + raise + except Exception as e: + logger.error("STA 回滚失败 / Rollback to AP failed: {}", e) diff --git a/ogscope/web/api/system/routes.py b/ogscope/web/api/system/routes.py index ad86842..cef7897 100644 --- a/ogscope/web/api/system/routes.py +++ b/ogscope/web/api/system/routes.py @@ -1,20 +1,16 @@ """ 系统相关API路由 """ + from fastapi import APIRouter + from ogscope.web.api.models.schemas import SystemInfo +from ogscope.web.api.system.services import system_info_service router = APIRouter() -@router.get("/system/info") -async def get_system_info(): - """获取系统信息""" - # TODO: 实现系统信息获取 - return { - "platform": "Orange Pi Zero 2W", - "os": "Debian", - "cpu_usage": 0.0, - "memory_usage": 0.0, - "temperature": 0.0, - } +@router.get("/system/info", response_model=SystemInfo) +async def get_system_info() -> SystemInfo: + """获取系统信息 / Get system information""" + return SystemInfo(**system_info_service.get_system_info()) diff --git a/ogscope/web/api/system/services.py b/ogscope/web/api/system/services.py new file mode 100644 index 0000000..6cca899 --- /dev/null +++ b/ogscope/web/api/system/services.py @@ -0,0 +1,200 @@ +""" +系统信息服务 / System information service +""" + +from __future__ import annotations + +import os +import platform +import time +from pathlib import Path +from threading import Lock +from typing import Any + + +class SystemInfoService: + """系统信息采集服务(低开销缓存) / System info collector with low-overhead cache.""" + + def __init__(self, cache_ttl_seconds: float = 10.0) -> None: + self._cache_ttl_seconds = cache_ttl_seconds + self._cache_data: dict[str, Any] | None = None + self._cache_timestamp = 0.0 + self._lock = Lock() + self._last_cpu_total: int | None = None + self._last_cpu_idle: int | None = None + + def get_system_info(self) -> dict[str, Any]: + """获取系统信息 / Get system information.""" + now = time.monotonic() + with self._lock: + if ( + self._cache_data is not None + and (now - self._cache_timestamp) < self._cache_ttl_seconds + ): + return self._cache_data + + data = { + "platform": self._read_platform_name(), + "os": self._read_os_name(), + "cpu_usage": round(self._read_cpu_usage_percent(), 2), + "memory_usage": round(self._read_memory_usage_percent(), 2), + "temperature": round(self._read_cpu_temperature_celsius(), 2), + "uptime_seconds": self._read_uptime_seconds(), + "load_average_1m": round(self._read_load_average_1m(), 2), + } + wifi_quality, wifi_signal_dbm, wifi_interface = self._read_wifi_metrics() + data["wifi_quality"] = wifi_quality + data["wifi_signal_dbm"] = wifi_signal_dbm + data["wifi_interface"] = wifi_interface + + self._cache_data = data + self._cache_timestamp = now + return data + + def _read_platform_name(self) -> str: + model_path = Path("/proc/device-tree/model") + if model_path.exists(): + try: + return model_path.read_text(encoding="utf-8", errors="ignore").strip( + "\x00 \n\t" + ) + except OSError: + pass + return platform.machine() or "Unknown" + + def _read_os_name(self) -> str: + os_release = Path("/etc/os-release") + if os_release.exists(): + try: + for line in os_release.read_text(encoding="utf-8").splitlines(): + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + except OSError: + pass + return f"{platform.system()} {platform.release()}".strip() + + def _read_cpu_usage_percent(self) -> float: + stat_path = Path("/proc/stat") + if not stat_path.exists(): + try: + load_1m = self._read_load_average_1m() + cpu_count = max(1, (os.cpu_count() or 1)) + return min(100.0, max(0.0, (load_1m / cpu_count) * 100.0)) + except (OSError, ValueError): + return 0.0 + + try: + first_line = stat_path.read_text(encoding="utf-8").splitlines()[0] + parts = first_line.split() + if len(parts) < 5 or parts[0] != "cpu": + return 0.0 + + values = [int(value) for value in parts[1:]] + idle = values[3] + (values[4] if len(values) > 4 else 0) + total = sum(values) + + if self._last_cpu_total is None or self._last_cpu_idle is None: + self._last_cpu_total = total + self._last_cpu_idle = idle + return 0.0 + + total_delta = total - self._last_cpu_total + idle_delta = idle - self._last_cpu_idle + self._last_cpu_total = total + self._last_cpu_idle = idle + + if total_delta <= 0: + return 0.0 + usage = (1.0 - (idle_delta / total_delta)) * 100.0 + return min(100.0, max(0.0, usage)) + except (OSError, ValueError, IndexError): + return 0.0 + + def _read_memory_usage_percent(self) -> float: + meminfo_path = Path("/proc/meminfo") + if not meminfo_path.exists(): + return 0.0 + + total_kb: int | None = None + available_kb: int | None = None + try: + for line in meminfo_path.read_text(encoding="utf-8").splitlines(): + if line.startswith("MemTotal:"): + total_kb = int(line.split()[1]) + elif line.startswith("MemAvailable:"): + available_kb = int(line.split()[1]) + if total_kb is not None and available_kb is not None: + break + except (OSError, ValueError): + return 0.0 + + if not total_kb or available_kb is None: + return 0.0 + used_kb = max(0, total_kb - available_kb) + return (used_kb / total_kb) * 100.0 + + def _read_cpu_temperature_celsius(self) -> float: + thermal_glob = Path("/sys/class/thermal") + if thermal_glob.exists(): + for zone in thermal_glob.glob("thermal_zone*/temp"): + try: + raw_value = zone.read_text(encoding="utf-8").strip() + temp = float(raw_value) + if temp > 1000: + return temp / 1000.0 + if temp > 0: + return temp + except (OSError, ValueError): + continue + return 0.0 + + def _read_wifi_metrics(self) -> tuple[float | None, float | None, str | None]: + wireless_path = Path("/proc/net/wireless") + if not wireless_path.exists(): + return None, None, None + + try: + lines = wireless_path.read_text(encoding="utf-8").splitlines() + except OSError: + return None, None, None + + # /proc/net/wireless 列顺序:iface、status、link、level、noise / Columns: iface, status, link, level, noise + # 示例 / Example: wlan0: 0000 45. -50. -256 (link 在 status 之后) + for line in lines[2:]: + if ":" not in line: + continue + interface, values_str = line.split(":", 1) + values = values_str.split() + if len(values) < 3: + continue + try: + link_quality = float(values[1].rstrip(".")) + signal_level = float(values[2].rstrip(".")) + quality_percent = max(0.0, min(100.0, (link_quality / 70.0) * 100.0)) + return ( + round(quality_percent, 2), + round(signal_level, 2), + interface.strip(), + ) + except ValueError: + continue + return None, None, None + + def _read_uptime_seconds(self) -> int: + uptime_path = Path("/proc/uptime") + if not uptime_path.exists(): + return 0 + try: + text = uptime_path.read_text(encoding="utf-8").strip() + return int(float(text.split()[0])) + except (OSError, ValueError, IndexError): + return 0 + + def _read_load_average_1m(self) -> float: + try: + return float(os.getloadavg()[0]) + except OSError: + return 0.0 + + +system_info_service = SystemInfoService() diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 210d00f..bdbed7b 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -1,14 +1,18 @@ """ FastAPI Web 应用 """ + +import asyncio +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager -from typing import AsyncGenerator +from pathlib import Path from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.openapi.docs import get_redoc_html +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from fastapi.responses import HTMLResponse from loguru import logger from ogscope.__version__ import __version__ @@ -18,84 +22,230 @@ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: - """应用生命周期管理""" - # 启动时执行 + """应用生命周期管理 / Application life cycle management""" + # 启动时执行 / Execute at startup logger.info("初始化 Web 应用...") - - # TODO: 初始化数据库连接 - # TODO: 初始化相机 - # TODO: 初始化其他资源 - + + # 真实硬件下后台预热相机:不阻塞 Uvicorn 就绪;首次请求仍可在锁内完成初始化 + # Background warm-up on real hardware: do not block server readiness; first request can still init under lock. + async def _warm_camera() -> None: + try: + from ogscope.utils.environment import should_use_simulation_mode + + if not should_use_simulation_mode(): + from ogscope.web.camera_shared import get_camera_manager + + await get_camera_manager().ensure_started() + logger.info( + "相机已启动并进入共享预览缓存 / Camera streaming (shared preview cache)" + ) + except Exception as e: + logger.warning( + f"启动时相机预热失败,将在首次请求时重试 / Camera warm-up failed, retry on demand: {e}" + ) + + async def _warm_solver() -> None: + try: + from ogscope.algorithms.plate_solve.solver import warmup_tetra3 + + await asyncio.to_thread(warmup_tetra3) + logger.info("解算器已预热 / Plate solver warmed up") + except Exception as e: + logger.warning( + f"启动时解算器预热失败,将在首次解算时重试 / Solver warm-up failed, retry on first solve: {e}" + ) + + asyncio.create_task(_warm_camera()) + # 解算器预热改为启动阶段阻塞完成,避免首个解算请求与后台预热竞态导致“第一次明显变慢” + # Warm the solver synchronously during startup to avoid first-request cold-start race. + await _warm_solver() + + try: + from ogscope.hardware.wifi_emergency_gpio import wifi_emergency_gpio_monitor + + wifi_emergency_gpio_monitor.start() + except Exception as e: + logger.warning("应急 GPIO 启动失败 / Emergency GPIO start failed: {}", e) + yield - - # 关闭时执行 + + # 关闭时执行 / Execute on shutdown logger.info("清理资源...") - # TODO: 关闭数据库连接 - # TODO: 释放相机资源 + try: + from ogscope.hardware.wifi_emergency_gpio import wifi_emergency_gpio_monitor + wifi_emergency_gpio_monitor.stop() + except Exception as e: + logger.warning("应急 GPIO 停止异常 / Emergency GPIO stop error: {}", e) + try: + from ogscope.utils.environment import should_use_simulation_mode -# 创建 FastAPI 应用 + if not should_use_simulation_mode(): + from ogscope.web.camera_shared import get_camera_manager + + await get_camera_manager().stop() + except Exception as e: + logger.warning(f"关闭相机失败 / Failed to stop camera on shutdown: {e}") + + +# API 文档分组标签 / API documentation group tags +openapi_tags = [ + { + "name": "Camera - 相机", + "description": "相机控制与图像获取 / Camera control and image capture", + }, + { + "name": "Alignment - 极轴校准", + "description": "极轴校准流程与状态 / Polar alignment workflow and status", + }, + { + "name": "System - 系统", + "description": "系统信息与配置管理 / System information and configuration", + }, + { + "name": "Debug - 调试", + "description": "调试控制台接口 / Debug console endpoints", + }, + { + "name": "Analysis - 分析", + "description": "素材分析与任务管理 / Asset analysis and job management", + }, + { + "name": "Network - 网络", + "description": "WiFi AP/STA 切换 / WiFi AP vs STA switching", + }, + { + "name": "Catalog - 星表", + "description": "星表下载、索引与状态 / Catalog download, indexing, and status", + }, +] + +# 创建 FastAPI 应用(禁用默认 ReDoc,使用自定义稳定版本) +# Create a FastAPI application (disable default ReDoc, use custom stable version) app = FastAPI( title="OGScope API", description="电子极轴镜 Web API", version=__version__, lifespan=lifespan, + openapi_tags=openapi_tags, + redoc_url=None, ) -# 初始化模板引擎 +# 初始化模板引擎 / Initialize template engine settings = get_settings() templates = Jinja2Templates(directory=str(settings.template_dir)) -# 配置 CORS (允许跨域请求) + +def _asset_stamp(path: Path) -> int: + try: + return int(path.stat().st_mtime) + except Exception: + return 0 + + +# 配置 CORS (允许跨域请求) / Configure CORS (allow cross-origin requests) app.add_middleware( CORSMiddleware, - allow_origins=["*"], # 生产环境应该限制具体域名 + allow_origins=[ + "*" + ], # 生产环境应该限制具体域名 / Production environments should restrict specific domain names allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) -# 挂载静态文件 +# 挂载静态文件 / Mount static files if settings.static_dir.exists(): app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static") -# 挂载Web模板和manifest +# 挂载Web模板和manifest / Mount web templates and manifests if settings.template_dir.exists(): from fastapi.staticfiles import StaticFiles - app.mount("/web", StaticFiles(directory=str(settings.template_dir.parent)), name="web") -# 注册路由 + app.mount( + "/web", StaticFiles(directory=str(settings.template_dir.parent)), name="web" + ) + +# 注册路由 / Register route app.include_router(api_router, prefix="/api") @app.get("/", response_class=HTMLResponse) async def root(request: Request): - """根路径 - 返回主页面""" + """根路径 - 返回主页面 / Root path - return to main page""" + return templates.TemplateResponse( + "index.html", + {"request": request, "version": __version__, "app_name": "OGScope"}, + ) + + +@app.get("/debug", response_class=HTMLResponse) +async def debug_console(request: Request): + """统一调试后台入口 / Unified debug admin entry.""" + admin_index = settings.static_dir / "analysis-lab" / "system.html" + if admin_index.is_file(): + return FileResponse(admin_index) + ds_js = settings.static_dir / "js" / "debug-system.js" return templates.TemplateResponse( - "index.html", + "debug_system.html", { "request": request, "version": __version__, - "app_name": "OGScope" - } + "app_name": "OGScope System Debug", + "debug_system_assets_version": _asset_stamp(ds_js), + "http_port": settings.port, + }, ) -@app.get("/debug", response_class=HTMLResponse) -async def debug_console(request: Request): - """调试控制台页面""" +@app.get("/debug/system", response_class=HTMLResponse) +async def debug_system_console(request: Request): + """兼容旧系统调试入口,重定向到新后台 / Legacy system debug entry.""" + _ = request + return RedirectResponse(url="/debug", status_code=307) + + +@app.get("/debug/camera", response_class=HTMLResponse) +async def debug_camera_console(request: Request): + """相机调试页入口(新 SPA 优先,旧页兜底)/ Camera debug entry (SPA first, legacy fallback).""" + camera_index = settings.static_dir / "analysis-lab" / "camera.html" + if camera_index.is_file(): + return FileResponse(camera_index) + debug_js_path = settings.static_dir / "js" / "debug.js" return templates.TemplateResponse( - "debug.html", + "debug.html", { "request": request, "version": __version__, - "app_name": "OGScope Debug Console" - } + "app_name": "OGScope Debug Console", + "debug_assets_version": _asset_stamp(debug_js_path), + }, ) + +@app.get("/debug/analysis", response_class=HTMLResponse) +async def debug_analysis_console(request: Request): + """星空解算控制台(Vite 构建 SPA)或回退旧模板 / Plate solve console SPA or legacy template.""" + lab_index = settings.static_dir / "analysis-lab" / "index.html" + if lab_index.is_file(): + return FileResponse(lab_index) + da_js = settings.static_dir / "js" / "debug-analysis.js" + da_css = settings.static_dir / "css" / "debug-analysis.css" + debug_analysis_assets_version = f"{_asset_stamp(da_js)}-{_asset_stamp(da_css)}" + return templates.TemplateResponse( + "debug_analysis.html", + { + "request": request, + "version": __version__, + "app_name": "OGScope Plate Solve Debug Console", + "debug_analysis_assets_version": debug_analysis_assets_version, + }, + ) + + @app.get("/api") async def api_root(): - """API根路径""" + """API根路径 / API root path""" return { "name": "OGScope", "version": __version__, @@ -104,16 +254,27 @@ async def api_root(): "endpoints": { "camera": "/api/camera/", "alignment": "/api/alignment/", - "system": "/api/system/" - } + "system": "/api/system/", + "network": "/api/network/", + "analysis": "/api/analysis/", + }, } +@app.get("/redoc", include_in_schema=False) +async def custom_redoc(): + """自定义 ReDoc 页面,使用固定稳定版本 / Custom ReDoc page with pinned stable version""" + return get_redoc_html( + openapi_url=app.openapi_url, + title=f"{app.title} - ReDoc", + redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js", + ) + + @app.get("/health") async def health_check(): - """健康检查""" + """健康检查 / health check""" return { "status": "healthy", "version": __version__, } - diff --git a/ogscope/web/camera_shared.py b/ogscope/web/camera_shared.py new file mode 100644 index 0000000..28be919 --- /dev/null +++ b/ogscope/web/camera_shared.py @@ -0,0 +1,362 @@ +""" +统一相机管理与共享帧总线 / Unified camera manager and shared frame bus. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from dataclasses import dataclass +from threading import Lock +from typing import Any, Callable + + +@dataclass(slots=True) +class SharedFrame: + """共享帧快照 / Shared frame snapshot.""" + + frame_id: int + timestamp: float + raw_frame: Any | None + jpeg_frame: bytes | None + width: int + height: int + + +class CameraManager: + """全局单相机控制器(控制面+数据面)/ Global single-camera controller.""" + + def __init__(self) -> None: + self._camera = None + self._control_lock = asyncio.Lock() + self._read_lock = Lock() + self._frame_lock = Lock() + self._grabber_task: asyncio.Task | None = None + self._frame_id = 0 + self._latest_raw = None + self._latest_jpeg: bytes | None = None + self._latest_ts = 0.0 + self._latest_w = 0 + self._latest_h = 0 + self._runtime_overrides: dict[str, Any] = {} + self._jpeg_quality = int(os.getenv("OGSCOPE_PREVIEW_JPEG_QUALITY", "75")) + self._target_fps = max(1, int(os.getenv("OGSCOPE_SHARED_PREVIEW_FPS", "8"))) + # 是否常驻 raw 帧缓存;默认关闭以降低内存占用(分析路径可同步抓帧) + # Whether to retain raw frame cache; default off to reduce RAM (analysis can sync-grab). + self._keep_raw_cache = bool( + int(os.getenv("OGSCOPE_KEEP_RAW_CACHE", "0") or "0") + ) + self._logger = logging.getLogger(__name__) + + @property + def preview_jpeg_quality(self) -> int: + """共享抓帧 JPEG 质量(与缓存一致)/ Shared grabber JPEG quality (matches cache).""" + return int(self._jpeg_quality) + + def _build_base_config(self) -> dict[str, Any]: + from ogscope.config import get_settings + + settings = get_settings() + base = { + "type": "imx327_mipi", + "width": settings.camera_width, + "height": settings.camera_height, + "fps": max(1, int(getattr(settings, "camera_fps", 5) or 5)), + "exposure_us": settings.camera_exposure, + "analogue_gain": settings.camera_gain, + "auto_exposure": True, + "ae_polar_preset": settings.camera_ae_polar_preset, + "ae_exposure_value": settings.camera_ae_exposure_value, + "rotation": 180, + "sampling_mode": getattr(settings, "camera_sampling_mode", "native"), + "noise_reduction": 0, + "white_balance_mode": "auto", + "white_balance_gain_r": 1.0, + "white_balance_gain_b": 1.0, + "contrast": 1.0, + "brightness": 0.0, + "saturation": 1.0, + "sharpness": 1.0, + "night_mode": False, + "color_mode": "color", + } + return {**base, **self._runtime_overrides} + + def _create_camera_sync(self): + from ogscope.hardware.camera import create_camera + + config = self._build_base_config() + camera = create_camera(config) + if camera and camera.initialize(): + return camera + return None + + def _encode_preview_jpeg_sync(self, frame) -> bytes | None: + try: + import cv2 + + ok, buf = cv2.imencode( + ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, int(self._jpeg_quality)] + ) + if not ok: + return None + return buf.tobytes() + except Exception: + return None + + def _read_frame_sync(self): + with self._read_lock: + if self._camera is None or not getattr(self._camera, "is_capturing", False): + return None + return self._camera.get_video_frame() + + async def ensure_started(self) -> None: + """确保单相机进入采集并启动共享帧抓取 / Ensure capture and shared frame grabber.""" + async with self._control_lock: + if self._camera is None: + self._camera = await asyncio.to_thread(self._create_camera_sync) + if self._camera is None: + raise RuntimeError("相机初始化失败 / Camera init failed") + if not getattr(self._camera, "is_capturing", False): + ok = await asyncio.to_thread(self._camera.start_capture) + if not ok: + raise RuntimeError("相机启动失败 / Camera start failed") + await self._ensure_grabber_locked() + + async def stop(self) -> None: + """停止相机采集 / Stop camera capture.""" + async with self._control_lock: + await self._stop_grabber_locked() + if self._camera is not None and getattr( + self._camera, "is_capturing", False + ): + await asyncio.to_thread(self._camera.stop_capture) + + async def pause_grabber(self) -> None: + """暂停共享抓帧任务(保留采集)/ Pause shared frame grabber only.""" + async with self._control_lock: + await self._stop_grabber_locked() + + async def resume_grabber(self) -> None: + """恢复共享抓帧任务 / Resume shared frame grabber.""" + async with self._control_lock: + if self._camera is not None and getattr( + self._camera, "is_capturing", False + ): + await self._ensure_grabber_locked() + + def _call_with_read_lock(self, fn: Callable[[], Any]) -> Any: + """在读锁下执行阻塞操作,避免与抓帧并发 / Run blocking op under read lock.""" + with self._read_lock: + return fn() + + async def reconfigure_camera( + self, + operation_name: str, + fn: Callable[[], Any], + *, + timeout_sec: float = 10.0, + ) -> Any: + """受控重配置:同一临界区内停抓帧->改参->恢复 / Controlled reconfigure.""" + async with self._control_lock: + t0 = time.time() + await self._stop_grabber_locked() + try: + result = await asyncio.wait_for( + asyncio.to_thread(self._call_with_read_lock, fn), + timeout=timeout_sec, + ) + return result + finally: + if self._camera is not None and getattr( + self._camera, "is_capturing", False + ): + await self._ensure_grabber_locked() + self._logger.info( + "camera_reconfigure_done op=%s cost_ms=%.2f", + operation_name, + (time.time() - t0) * 1000.0, + ) + + 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: + if not self._grabber_task: + return + self._grabber_task.cancel() + try: + await asyncio.wait_for(self._grabber_task, timeout=2.0) + except asyncio.CancelledError: + # 抓帧任务被取消属于正常停止流程,不应向上抛出 + # Task cancellation is expected during graceful stop. + pass + except Exception: + pass + self._grabber_task = None + + async def _grabber_loop(self) -> None: + interval = 1.0 / float(self._target_fps) + loop = asyncio.get_running_loop() + try: + while True: + t0 = time.time() + try: + frame = await asyncio.to_thread(self._read_frame_sync) + if frame is not None: + jpeg = await loop.run_in_executor( + None, self._encode_preview_jpeg_sync, frame + ) + h = int(getattr(frame, "shape", [0, 0])[0] or 0) + w = int(getattr(frame, "shape", [0, 0])[1] or 0) + with self._frame_lock: + self._frame_id += 1 + # 默认不保留 raw,避免与 JPEG 双份常驻;需要时设 OGSCOPE_KEEP_RAW_CACHE=1 + # By default do not retain raw to avoid dual large buffers; set env to keep. + self._latest_raw = frame if self._keep_raw_cache else None + self._latest_jpeg = jpeg + self._latest_ts = time.time() + self._latest_w = w + self._latest_h = h + except Exception as e: + self._logger.error(f"共享抓帧循环异常 / Shared grabber error: {e}") + spent = time.time() - t0 + await asyncio.sleep(max(0.0, interval - spent)) + except asyncio.CancelledError: + raise + + def get_camera_instance(self): + """兼容接口:返回全局相机实例 / Compat accessor for global camera object.""" + return self._camera + + def ensure_camera_instance_sync(self): + """兼容旧接口:仅返回当前实例,不再在锁外触发初始化 / Compat: return existing camera only.""" + return self._camera + + def attach_camera_instance(self, camera: Any) -> None: + """注入现有相机实例(测试/兼容)/ Attach existing camera instance (tests/compat).""" + self._camera = camera + + async def status(self) -> dict[str, Any]: + cam = self._camera + if cam is None: + return { + "connected": False, + "streaming": False, + "runtime_overrides": self._runtime_overrides, + } + info = await asyncio.to_thread(cam.get_camera_info) + return { + "connected": bool(getattr(cam, "is_initialized", False)), + "streaming": bool(getattr(cam, "is_capturing", False)), + "info": info, + "runtime_overrides": self._runtime_overrides, + } + + async def get_preview_frame( + self, since_id: int | None = None, wait_timeout_sec: float = 0.8 + ) -> tuple[int, SharedFrame | None]: + """读取预览帧;如未更新则返回 304 / Get preview frame; return 304 if unchanged.""" + await self.ensure_started() + deadline = time.time() + max(0.0, float(wait_timeout_sec)) + while True: + with self._frame_lock: + if self._frame_id > 0 and self._latest_jpeg is not None: + if since_id is not None and since_id == self._frame_id: + return 304, None + snap = SharedFrame( + frame_id=self._frame_id, + timestamp=self._latest_ts, + raw_frame=self._latest_raw, + jpeg_frame=self._latest_jpeg, + width=self._latest_w, + height=self._latest_h, + ) + return 200, snap + if time.time() >= deadline: + return 503, None + await asyncio.sleep(0.02) + + async def get_raw_frame(self) -> tuple[Any, int, float]: + """读取分析帧 / Get frame for analysis.""" + await self.ensure_started() + with self._frame_lock: + if self._latest_raw is not None: + try: + frame = self._latest_raw.copy() + except Exception: + frame = self._latest_raw + return frame, self._frame_id, self._latest_ts + # 无常驻 raw 时同步抓一帧,供解算使用(不写入 _latest_raw,除非开启 keep cache) + # Sync-grab when raw cache is disabled; avoids breaking analysis while saving RAM. + frame = await asyncio.to_thread(self._read_frame_sync) + if frame is None: + raise RuntimeError("无可用视频帧 / No frame available") + with self._frame_lock: + fid = self._frame_id + ts = self._latest_ts + try: + out = frame.copy() + except Exception: + out = frame + return out, fid, ts + + async def get_cached_frame_snapshot(self) -> SharedFrame | None: + """读取当前缓存帧快照(不触发 ensure)/ Read cached snapshot without ensure.""" + with self._frame_lock: + if self._frame_id <= 0: + return None + return SharedFrame( + frame_id=self._frame_id, + timestamp=self._latest_ts, + raw_frame=self._latest_raw, + jpeg_frame=self._latest_jpeg, + width=self._latest_w, + height=self._latest_h, + ) + + @staticmethod + def encode_frame( + raw_frame: Any, image_format: str = "jpeg", quality: int = 75 + ) -> bytes | None: + """将原始帧编码为图像字节 / Encode raw frame to image bytes.""" + try: + import cv2 + + if image_format.lower() == "png": + ok, buf = cv2.imencode(".png", raw_frame) + else: + ok, buf = cv2.imencode( + ".jpg", + raw_frame, + [cv2.IMWRITE_JPEG_QUALITY, int(max(10, min(100, quality)))], + ) + if not ok: + return None + return buf.tobytes() + except Exception: + return None + + def update_runtime_overrides(self, updates: dict[str, Any]) -> None: + """更新运行时覆盖参数(不落盘)/ Update runtime overrides (memory only).""" + self._runtime_overrides.update(updates) + + def get_runtime_overrides(self) -> dict[str, Any]: + """读取运行时覆盖参数 / Read runtime overrides.""" + return dict(self._runtime_overrides) + + def clear_runtime_overrides(self) -> None: + """清空运行时覆盖参数 / Clear runtime overrides.""" + self._runtime_overrides.clear() + + +_camera_manager = CameraManager() + + +def get_camera_manager() -> CameraManager: + """获取全局相机管理器 / Get global camera manager.""" + return _camera_manager diff --git a/poetry.lock b/poetry.lock index 48d07d3..ae7f56e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -12,28 +12,6 @@ files = [ {file = "aiofiles-23.2.1.tar.gz", hash = "sha256:84ec2218d8419404abcb9f0c02df3f34c6e0a68ed41072acfb1cef5cbc29051a"}, ] -[[package]] -name = "alembic" -version = "1.16.5" -description = "A database migration tool for SQLAlchemy." -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3"}, - {file = "alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e"}, -] - -[package.dependencies] -Mako = "*" -SQLAlchemy = ">=1.4.0" -tomli = {version = "*", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.12" - -[package.extras] -tz = ["tzdata"] - [[package]] name = "alembic" version = "1.17.0" @@ -41,7 +19,6 @@ description = "A database migration tool for SQLAlchemy." optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ {file = "alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99"}, {file = "alembic-1.17.0.tar.gz", hash = "sha256:4652a0b3e19616b57d652b82bfa5e38bf5dbea0813eed971612671cb9e90c0fe"}, @@ -89,60 +66,6 @@ typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] trio = ["trio (>=0.31.0)"] -[[package]] -name = "astropy" -version = "6.0.1" -description = "Astronomy and astrophysics core library" -optional = false -python-versions = ">=3.9" -groups = ["main"] -markers = "python_version == \"3.9\"" -files = [ - {file = "astropy-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b5ff962b0e586953f95b63ec047e1d7a3b6a12a13d11c6e909e0bcd3e05b445"}, - {file = "astropy-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:129ed1fb1d23e6fbf8b8e697c2e7340d99bc6271b8c59f9572f3f47063a42e6a"}, - {file = "astropy-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e998ee0ffa58342b4d44f2843b036015e3a6326b53185c5361fea4430658466"}, - {file = "astropy-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c33e3d746c3e7a324dbd76b236fe1e44304d5b6d941a1f724f419d01666d6d88"}, - {file = "astropy-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2f53caf9efebcc9040a92c977dcdae78dd0ff4de218fd316e4fcaffd9ace8dc1"}, - {file = "astropy-6.0.1-cp310-cp310-win32.whl", hash = "sha256:242b8f101301ab303366109d0dfe3cf0db745bf778f7b859fb486105197577d1"}, - {file = "astropy-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:1db9e95438472f6ed53fa2f4e2811c2d84f4085eeacc3cb8820d770d1ea61d1c"}, - {file = "astropy-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c682967736228cc4477e63db0e8854375dd31d755de55b30256de98f1f7b7c23"}, - {file = "astropy-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5208b6f10956ca92efb73375364c81a7df365b441b07f4941a24ee0f1bd9e292"}, - {file = "astropy-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f28facb5800c0617f233c1db0e622da83de1f74ca28d0ff8646e360d4fda74e"}, - {file = "astropy-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c00922548a666b026e2630a563090341d74c8222066e9c84c9673395bca7363"}, - {file = "astropy-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9b3bf27c51fb46bba993695eebd0c39a4e2a792b707e65b28ac4e8ae703f93d4"}, - {file = "astropy-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1f183ab42655ad09b064a4e8eb9cd1eaa138b90ca2f0cd82a200afda062063a5"}, - {file = "astropy-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:d934aff5fe81e84a45098e281f969976963cc16b3401176a8171affd84301a27"}, - {file = "astropy-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4fdd54fa57b85d50c4b83ab7ffd90ba2ffcc3d725e3f8d5ffa1ff5f500ef6b97"}, - {file = "astropy-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d1eb40fe68121753f43fc82d618a2eae53dd0731689e124ef9e002aa2c241c4f"}, - {file = "astropy-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8bc267738a85f633142c246dceefa722b653e7ba99f02e86dd9a7b980467eafc"}, - {file = "astropy-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e604898ca1790c9fd2e2dc83b38f9185556ea618a3a6e6be31c286fafbebd165"}, - {file = "astropy-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:034dff5994428fb89813f40a18600dd8804128c52edf3d1baa8936eca3738de4"}, - {file = "astropy-6.0.1-cp312-cp312-win32.whl", hash = "sha256:87ebbae7ba52f4de9b9f45029a3167d6515399138048d0b734c9033fda7fd723"}, - {file = "astropy-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8fbd6d88935749ae892445691ac0dbd1923fc6d8094753a35150fc7756118fe3"}, - {file = "astropy-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f18536d6f97faa81ed6c9af7bb2e27b376b41b27399f862e3b13387538c966b9"}, - {file = "astropy-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:764992af1ee1cd6d6f26373d09ddb5ede639d025ce9ff658b3b6580dc2ba4ec6"}, - {file = "astropy-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34fd2bb39cbfa6a8815b5cc99008d59057b9d341db00c67dbb40a3784a8dfb08"}, - {file = "astropy-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca9da00bfa95fbf8475d22aba6d7d046f3821a107b733fc7c7c35c74fcfa2bbf"}, - {file = "astropy-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:15a5da8a0a84d75b55fafd56630578131c3c9186e4e486b4d2fb15c349b844d0"}, - {file = "astropy-6.0.1-cp39-cp39-win32.whl", hash = "sha256:46cbadf360bbadb6a106217e104b91f85dd618658caffdaab5d54a14d0d52563"}, - {file = "astropy-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:eaff9388a2fed0757bd0b4c41c9346d0edea9e7e938a4bfa8070eaabbb538a23"}, - {file = "astropy-6.0.1.tar.gz", hash = "sha256:89a975de356d0608e74f1f493442fb3acbbb7a85b739e074460bb0340014b39c"}, -] - -[package.dependencies] -astropy-iers-data = ">=0.2024.2.26.0.28.55" -numpy = ">=1.22,<2" -packaging = ">=19.0" -pyerfa = ">=2.0.1.1" -PyYAML = ">=3.13" - -[package.extras] -all = ["asdf-astropy (>=0.3)", "astropy[recommended]", "beautifulsoup4", "bleach", "bottleneck", "certifi", "dask[array]", "fsspec[http] (>=2023.4.0)", "h5py", "html5lib", "ipython (>=4.2)", "jplephem", "mpmath", "pandas", "pre-commit", "pyarrow (>=5.0.0)", "pytest (>=7.0)", "pytz", "s3fs (>=2023.4.0)", "sortedcontainers", "typing-extensions (>=3.10.0.1)"] -docs = ["Jinja2 (>=3.1.3)", "astropy[recommended]", "pytest (>=7.0)", "sphinx", "sphinx-astropy[confv2] (>=1.9.1)", "sphinx-changelog (>=1.2.0)", "sphinx-design", "tomli ; python_version < \"3.11\""] -recommended = ["matplotlib (>=3.3,!=3.4.0,!=3.5.2)", "scipy (>=1.5)"] -test = ["pytest (>=7.0)", "pytest-astropy (>=0.10)", "pytest-astropy-header (>=0.2.1)", "pytest-doctestplus (>=0.12)", "pytest-xdist", "threadpoolctl"] -test-all = ["astropy[test]", "coverage[toml]", "ipython (>=4.2)", "objgraph", "sgp4 (>=2.3)", "skyfield (>=1.20)"] - [[package]] name = "astropy" version = "6.1.7" @@ -150,7 +73,6 @@ description = "Astronomy and astrophysics core library" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" files = [ {file = "astropy-6.1.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:be954c5f7707a089609053665aeb76493b79e5c4753c39486761bc6d137bf040"}, {file = "astropy-6.1.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b5e48df5ab2e3e521e82a7233a4b1159d071e64e6cbb76c45415dc68d3b97af1"}, @@ -301,22 +223,6 @@ files = [ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] -[[package]] -name = "click" -version = "8.1.8" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -groups = ["main", "dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, - {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - [[package]] name = "click" version = "8.3.0" @@ -324,7 +230,6 @@ description = "Composable command line interface toolkit" optional = false python-versions = ">=3.10" groups = ["main", "dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, @@ -346,127 +251,6 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "coverage" -version = "7.10.7" -description = "Code coverage measurement for Python" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, - {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, - {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, - {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, - {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, - {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, - {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, - {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, - {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, - {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, - {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, - {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, - {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, - {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, - {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, - {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, - {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, - {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, - {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, - {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, - {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, - {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, - {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, - {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, - {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, - {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, - {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, - {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, - {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, - {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, - {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, - {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, - {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, - {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, - {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, - {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, - {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, - {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, - {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, - {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, - {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, - {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, - {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, - {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, - {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, - {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, - {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, - {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, - {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, -] - -[package.dependencies] -tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} - -[package.extras] -toml = ["tomli ; python_full_version <= \"3.11.0a6\""] - [[package]] name = "coverage" version = "7.11.0" @@ -474,7 +258,6 @@ description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "coverage-7.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:eb53f1e8adeeb2e78962bade0c08bfdc461853c7969706ed901821e009b35e31"}, {file = "coverage-7.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9a03ec6cb9f40a5c360f138b88266fd8f58408d71e89f536b4f91d85721d075"}, @@ -607,7 +390,7 @@ description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" +markers = "python_version == \"3.10\"" files = [ {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, @@ -654,19 +437,6 @@ typing-extensions = ">=4.8.0" [package.extras] all = ["email-validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -[[package]] -name = "filelock" -version = "3.19.1" -description = "A platform independent file lock." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, - {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, -] - [[package]] name = "filelock" version = "3.20.0" @@ -674,7 +444,6 @@ description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2"}, {file = "filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4"}, @@ -697,6 +466,8 @@ files = [ {file = "greenlet-3.2.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2ca18a03a8cfb5b25bc1cbe20f3d9a4c80d8c3b13ba3df49ac3961af0b1018d"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9fe0a28a7b952a21e2c062cd5756d34354117796c6d9215a87f55e38d15402c5"}, {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8854167e06950ca75b898b104b63cc646573aa5fef1353d4508ecdd1ee76254f"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f47617f698838ba98f4ff4189aef02e7343952df3a615f847bb575c3feb177a7"}, + {file = "greenlet-3.2.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:af41be48a4f60429d5cad9d22175217805098a9ef7c40bfef44f7669fb9d74d8"}, {file = "greenlet-3.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:73f49b5368b5359d04e18d15828eecc1806033db5233397748f4ca813ff1056c"}, {file = "greenlet-3.2.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:96378df1de302bc38e99c3a9aa311967b7dc80ced1dcc6f171e99842987882a2"}, {file = "greenlet-3.2.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ee8fae0519a337f2329cb78bd7a8e128ec0f881073d43f023c7b8d4831d5246"}, @@ -706,6 +477,8 @@ files = [ {file = "greenlet-3.2.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2523e5246274f54fdadbce8494458a2ebdcdbc7b802318466ac5606d3cded1f8"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:1987de92fec508535687fb807a5cea1560f6196285a4cde35c100b8cd632cc52"}, {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:55e9c5affaa6775e2c6b67659f3a71684de4c549b3dd9afca3bc773533d284fa"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c9c6de1940a7d828635fbd254d69db79e54619f165ee7ce32fda763a9cb6a58c"}, + {file = "greenlet-3.2.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03c5136e7be905045160b1b9fdca93dd6727b180feeafda6818e6496434ed8c5"}, {file = "greenlet-3.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:9c40adce87eaa9ddb593ccb0fa6a07caf34015a29bf8d344811665b573138db9"}, {file = "greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd"}, {file = "greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb"}, @@ -715,6 +488,8 @@ files = [ {file = "greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0"}, {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee7a6ec486883397d70eec05059353b8e83eca9168b9f3f9a361971e77e0bcd0"}, + {file = "greenlet-3.2.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:326d234cbf337c9c3def0676412eb7040a35a768efc92504b947b3e9cfc7543d"}, {file = "greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02"}, {file = "greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31"}, {file = "greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945"}, @@ -724,6 +499,8 @@ files = [ {file = "greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b"}, {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b"}, + {file = "greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929"}, {file = "greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b"}, {file = "greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f"}, @@ -731,6 +508,8 @@ files = [ {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735"}, {file = "greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269"}, + {file = "greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681"}, {file = "greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01"}, {file = "greenlet-3.2.4-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:b6a7c19cf0d2742d0809a4c05975db036fdff50cd294a93632d6a310bf9ac02c"}, {file = "greenlet-3.2.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:27890167f55d2387576d1f41d9487ef171849ea0359ce1510ca6e06c8bece11d"}, @@ -740,6 +519,8 @@ files = [ {file = "greenlet-3.2.4-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9913f1a30e4526f432991f89ae263459b1c64d1608c0d22a5c79c287b3c70df"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b90654e092f928f110e0007f572007c9727b5265f7632c2fa7415b4689351594"}, {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81701fd84f26330f0d5f4944d4e92e61afe6319dcd9775e39396e39d7c3e5f98"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:28a3c6b7cd72a96f61b0e4b2a36f681025b60ae4779cc73c1535eb5f29560b10"}, + {file = "greenlet-3.2.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52206cd642670b0b320a1fd1cbfd95bca0e043179c1d8a045f2c6109dfe973be"}, {file = "greenlet-3.2.4-cp39-cp39-win32.whl", hash = "sha256:65458b409c1ed459ea899e939f0e1cdb14f58dbc803f2f93c5eab5694d32671b"}, {file = "greenlet-3.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:d2e685ade4dafd447ede19c31277a224a239a0a1a4eca4e6390efedf20260cfb"}, {file = "greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d"}, @@ -920,45 +701,6 @@ decorator = {version = "*", markers = "python_version > \"3.6\""} ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\""} tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} -[[package]] -name = "ipython" -version = "8.18.1" -description = "IPython: Productive Interactive Computing" -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "ipython-8.18.1-py3-none-any.whl", hash = "sha256:e8267419d72d81955ec1177f8a29aaa90ac80ad647499201119e2f05e99aa397"}, - {file = "ipython-8.18.1.tar.gz", hash = "sha256:ca6f079bb33457c66e233e4580ebfc4128855b4cf6370dddd73842a9563e8a27"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -decorator = "*" -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} -jedi = ">=0.16" -matplotlib-inline = "*" -pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} -prompt-toolkit = ">=3.0.41,<3.1.0" -pygments = ">=2.4.0" -stack-data = "*" -traitlets = ">=5" -typing-extensions = {version = "*", markers = "python_version < \"3.10\""} - -[package.extras] -all = ["black", "curio", "docrepr", "exceptiongroup", "ipykernel", "ipyparallel", "ipywidgets", "matplotlib", "matplotlib (!=3.2.0)", "nbconvert", "nbformat", "notebook", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "qtconsole", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "trio", "typing-extensions"] -black = ["black"] -doc = ["docrepr", "exceptiongroup", "ipykernel", "matplotlib", "pickleshare", "pytest (<7)", "pytest (<7.1)", "pytest-asyncio (<0.22)", "setuptools (>=18.5)", "sphinx (>=1.3)", "sphinx-rtd-theme", "stack-data", "testpath", "typing-extensions"] -kernel = ["ipykernel"] -nbconvert = ["nbconvert"] -nbformat = ["nbformat"] -notebook = ["ipywidgets", "notebook"] -parallel = ["ipyparallel"] -qtconsole = ["qtconsole"] -test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"] -test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"] - [[package]] name = "ipython" version = "8.37.0" @@ -966,7 +708,6 @@ description = "IPython: Productive Interactive Computing" optional = false python-versions = ">=3.10" groups = ["dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "ipython-8.37.0-py3-none-any.whl", hash = "sha256:ed87326596b878932dbcb171e3e698845434d8c61b8d8cd474bf663041a9dcf2"}, {file = "ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216"}, @@ -1292,76 +1033,88 @@ files = [ [[package]] name = "numpy" -version = "1.26.4" +version = "2.2.6" description = "Fundamental package for array computing in Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, - {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, - {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, - {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, - {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, - {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, - {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, - {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, - {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, - {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, - {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, - {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, - {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, - {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, - {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, - {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, - {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, - {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, - {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, - {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, - {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, - {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, - {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, -] - -[[package]] -name = "opencv-python" -version = "4.11.0.86" + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "opencv-python-headless" +version = "4.12.0.88" description = "Wrapper package for OpenCV python bindings." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b"}, - {file = "opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec"}, + {file = "opencv-python-headless-4.12.0.88.tar.gz", hash = "sha256:cfdc017ddf2e59b6c2f53bc12d74b6b0be7ded4ec59083ea70763921af2b6c09"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:1e58d664809b3350c1123484dd441e1667cd7bed3086db1b9ea1b6f6cb20b50e"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:365bb2e486b50feffc2d07a405b953a8f3e8eaa63865bc650034e5c71e7a5154"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:aeb4b13ecb8b4a0beb2668ea07928160ea7c2cd2d9b5ef571bbee6bafe9cc8d0"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:236c8df54a90f4d02076e6f9c1cc763d794542e886c576a6fee46ec8ff75a7a9"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:fde2cf5c51e4def5f2132d78e0c08f9c14783cd67356922182c6845b9af87dbd"}, + {file = "opencv_python_headless-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:86b413bdd6c6bf497832e346cd5371995de148e579b9774f8eba686dee3f5528"}, ] [package.dependencies] -numpy = [ - {version = ">=1.21.0", markers = "python_version == \"3.9\" and platform_system == \"Darwin\" and platform_machine == \"arm64\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.23.5", markers = "python_version == \"3.11\""}, - {version = ">=1.21.4", markers = "python_version == \"3.10\" and platform_system == \"Darwin\""}, - {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version == \"3.10\""}, - {version = ">=1.19.3", markers = "python_version >= \"3.8\" and platform_system == \"Linux\" and platform_machine == \"aarch64\" and python_version < \"3.10\" or python_version == \"3.9\" and platform_system != \"Darwin\" or python_version == \"3.9\" and platform_machine != \"arm64\""}, -] +numpy = {version = ">=2,<2.3.0", markers = "python_version >= \"3.9\""} [[package]] name = "packaging" @@ -1410,7 +1163,7 @@ description = "Pexpect allows easy control of interactive console applications." optional = false python-versions = "*" groups = ["dev"] -markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\" or python_version == \"3.9\" and sys_platform != \"win32\"" +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, {file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"}, @@ -1517,24 +1270,6 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa typing = ["typing-extensions ; python_version < \"3.10\""] xmp = ["defusedxml"] -[[package]] -name = "platformdirs" -version = "4.4.0" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.9" -groups = ["dev"] -markers = "python_version == \"3.9\"" -files = [ - {file = "platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85"}, - {file = "platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.14.1)"] - [[package]] name = "platformdirs" version = "4.5.0" @@ -1542,7 +1277,6 @@ description = "A small Python package for determining appropriate platform-speci optional = false python-versions = ">=3.10" groups = ["dev"] -markers = "python_version >= \"3.10\"" files = [ {file = "platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3"}, {file = "platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312"}, @@ -1610,7 +1344,7 @@ description = "Run a subprocess in a pseudo terminal" optional = false python-versions = "*" groups = ["dev"] -markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\" or python_version == \"3.9\" and sys_platform != \"win32\"" +markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" files = [ {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"}, @@ -2085,6 +1819,70 @@ files = [ {file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"}, ] +[[package]] +name = "scipy" +version = "1.15.3" +description = "Fundamental algorithms for scientific computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f"}, + {file = "scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82"}, + {file = "scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e"}, + {file = "scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c"}, + {file = "scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65"}, + {file = "scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889"}, + {file = "scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9"}, + {file = "scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594"}, + {file = "scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477"}, + {file = "scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45"}, + {file = "scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e"}, + {file = "scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539"}, + {file = "scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb"}, + {file = "scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825"}, + {file = "scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11"}, + {file = "scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126"}, + {file = "scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e"}, + {file = "scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723"}, + {file = "scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4"}, + {file = "scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5"}, + {file = "scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca"}, + {file = "scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf"}, +] + +[package.dependencies] +numpy = ">=1.23.5,<2.5" + +[package.extras] +dev = ["cython-lint (>=0.12.2)", "doit (>=0.36.0)", "mypy (==1.10.0)", "pycodestyle", "pydevtool", "rich-click", "ruff (>=0.0.292)", "types-psutil", "typing_extensions"] +doc = ["intersphinx_registry", "jupyterlite-pyodide-kernel", "jupyterlite-sphinx (>=0.19.1)", "jupytext", "matplotlib (>=3.5)", "myst-nb", "numpydoc", "pooch", "pydata-sphinx-theme (>=0.15.2)", "sphinx (>=5.0.0,<8.0.0)", "sphinx-copybutton", "sphinx-design (>=0.4.0)"] +test = ["Cython", "array-api-strict (>=2.0,<2.1.1)", "asv", "gmpy2", "hypothesis (>=6.30)", "meson", "mpmath", "ninja ; sys_platform != \"emscripten\"", "pooch", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "scikit-umfpack", "threadpoolctl"] + [[package]] name = "sgp4" version = "2.25" @@ -2316,7 +2114,6 @@ files = [ [package.dependencies] anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] @@ -2328,7 +2125,6 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "dev"] -markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, @@ -2373,6 +2169,7 @@ files = [ {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +markers = {main = "python_version == \"3.10\"", dev = "python_full_version <= \"3.11.0a6\""} [[package]] name = "traitlets" @@ -2777,5 +2574,5 @@ dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] [metadata] lock-version = "2.1" -python-versions = "^3.9" -content-hash = "bf6ffd41c8c0a39fc70b530c0dfb4792a0f4f1a236e3a47f98039bc4a501b259" +python-versions = "^3.10" +content-hash = "2f2492fd75140682277386e834480a190c21a6919e5087722cedf5d8982d0a67" diff --git a/pyproject.toml b/pyproject.toml index d6943e1..b42f959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ keywords = ["astronomy", "polar-alignment", "astrophotography", "raspberry-pi", packages = [{include = "ogscope"}] [tool.poetry.dependencies] -python = "^3.9" +python = "^3.10" # Web 框架 - FastAPI fastapi = "^0.109.0" @@ -26,6 +26,7 @@ aiofiles = "^23.2.1" # 异步文件操作 numpy = ">=2,<3" opencv-python-headless = ">=4.12,<5" pillow = ">=10,<13" +scipy = ">=1.10,<1.17" # 相机支持 (Raspberry Pi MIPI) # picamera2 = "^0.3.0" # 树莓派 MIPI 相机支持 (仅Linux) @@ -94,7 +95,11 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] line-length = 88 +# 保持 py39 以匹配既有代码风格,避免 UP007 等大规模改写 / Match legacy style; py310 enables many UP* rewrites target-version = "py39" +exclude = ["ogscope/vendor"] + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -107,15 +112,17 @@ select = [ ignore = [ "E501", # line too long (handled by black) "B008", # do not perform function calls in argument defaults + "B904", # raise from inside except (HTTPException 风格常见 / common in API handlers) "C901", # too complex ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = ["F401"] # 允许未使用的导入 +"ogscope/__init__.py" = ["E402"] # vendor 路径需在 import 前注入 / vendor path before imports [tool.black] line-length = 88 -target-version = ['py39'] +target-version = ['py310'] include = '\.pyi?$' extend-exclude = ''' /( @@ -128,11 +135,12 @@ extend-exclude = ''' | \.venv | build | dist + | ogscope/vendor )/ ''' [tool.mypy] -python_version = "3.9" +python_version = "3.10" warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false # 逐步启用 diff --git a/scripts/board-update.sh b/scripts/board-update.sh new file mode 100755 index 0000000..7d371db --- /dev/null +++ b/scripts/board-update.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# OGScope 开发板增量更新 / Board incremental update (after first install) +# +# 环境变量 / Environment: +# OGSCOPE_GIT_PULL=1 — 在更新前执行 git pull(需 git 仓库)/ Run git pull before update (requires .git) +# OGSCOPE_INSTALL_DEV=1 — poetry install 时包含 dev 依赖 / Include dev dependency group +# POETRY_INSTALLER_MAX_WORKERS — 默认 2,低配板可设为 1 / Default 2; set to 1 on low-RAM boards +# OGSCOPE_MIRROR=auto|cn|international — 与 install.sh 相同 / Same as install.sh +# OGSCOPE_SKIP_PLATE_DB=1 — 不复制 default_database.npz / Skip Tetra3 pattern DB copy +# OGSCOPE_FORCE_PLATE_DB=1 — 覆盖已存在的 data/plate_solve/default_database.npz / Overwrite pattern DB +# OGSCOPE_SKIP_NETWORK_SYNC=1 — 不同步 WiFi 切换脚本与 ensure-systemd(免密 sudo 不可用时可设)/ Skip WiFi script + ensure-systemd + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVICE_NAME="ogscope" + +cd "${PROJECT_DIR}" + +# 加载镜像逻辑 / Load mirror helpers +# shellcheck source=mirror.sh +source "${SCRIPT_DIR}/mirror.sh" +OGSCOPE_MIRROR_RESOLVED="$(ogscope_resolve_mirror)" +echo "🌐 镜像模式 / Mirror: ${OGSCOPE_MIRROR_RESOLVED}(OGSCOPE_MIRROR=${OGSCOPE_MIRROR:-auto})" + +if [ ! -f "${PROJECT_DIR}/pyproject.toml" ]; then + echo "❌ 未找到 pyproject.toml / pyproject.toml not found" + exit 1 +fi + +export PATH="${HOME}/.local/bin:${PATH}" +if ! command -v poetry >/dev/null 2>&1; then + echo "❌ 未找到 Poetry,请先运行 ./scripts/install.sh / Poetry not found; run ./scripts/install.sh first" + exit 1 +fi + +if [ "${OGSCOPE_GIT_PULL:-}" = "1" ]; then + if [ -d "${PROJECT_DIR}/.git" ]; then + echo "📥 git pull..." + git pull --ff-only + else + echo "⚠️ 非 git 仓库,跳过 git pull / Not a git repo; skipping git pull" + fi +fi + +# 与 install.sh 保持一致,避免 PEP 668 / Match install.sh; avoid PEP 668 issues +poetry config virtualenvs.create true +poetry config virtualenvs.in-project true +poetry config virtualenvs.options.system-site-packages true 2>/dev/null || true + +INSTALL_ARGS=(install --no-interaction) +if [ "${OGSCOPE_INSTALL_DEV:-}" = "1" ]; then + echo "📦 poetry install(含 dev / with dev)..." +else + INSTALL_ARGS+=(--only main) + echo "📦 poetry install --only main..." +fi + +export POETRY_INSTALLER_MAX_WORKERS="${POETRY_INSTALLER_MAX_WORKERS:-2}" + +if [ "${OGSCOPE_MIRROR_RESOLVED}" = "cn" ]; then + ogscope_export_pypi_mirror_cn +else + ogscope_export_pypi_mirror_international +fi + +poetry "${INSTALL_ARGS[@]}" + +# numpy/scipy 与 lock 一致;Poetry 偶发「无更新」但 wheel 未落盘 / Align deps with lock; retry if missing +if ! ogscope_verify_numpy_scipy; then + echo "⚠️ numpy/scipy 导入失败,使用 --no-cache 重试 poetry install / Import failed; retrying poetry with --no-cache" + poetry "${INSTALL_ARGS[@]}" --no-cache +fi +if ! ogscope_verify_numpy_scipy; then + echo "⚠️ 仍缺少 scipy,使用 pip 补装(与 pyproject 版本约束一致)/ scipy still missing; pip install (same constraints)" + poetry run pip install --no-cache-dir "scipy>=1.10,<1.17" +fi +if ! ogscope_verify_numpy_scipy; then + echo "❌ numpy/scipy 仍不可用。请删除 .venv 后重试: rm -rf .venv && OGSCOPE_MIRROR=cn ./scripts/board-update.sh" + echo "❌ Still failing. Try: rm -rf .venv && ./scripts/board-update.sh" + exit 1 +fi +echo "✅ numpy/scipy 已就绪 / numpy & scipy OK" + +VENV_PYTHON="$(poetry env info --path)/bin/python" +SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" +ogscope_sync_systemd_execstart_if_needed "${SERVICE_PATH}" "${VENV_PYTHON}" + +chmod +x "${PROJECT_DIR}/scripts/ogscope-network-boot.sh" 2>/dev/null || true +ogscope_sync_network_boot_unit_if_needed "${PROJECT_DIR}" + +ogscope_sync_network_board_artifacts_if_needed "${PROJECT_DIR}" + +ogscope_sync_plate_solve_database_if_needed "${PROJECT_DIR}" +ogscope_report_plate_solve_database_status "${PROJECT_DIR}" + +echo "🔄 重启服务 / Restarting service..." +sudo systemctl daemon-reload +sudo systemctl restart "${SERVICE_NAME}" + +sleep 2 +sudo systemctl --no-pager status "${SERVICE_NAME}" || true + +echo "" +echo "✅ 更新完成 / Update done. 日志 / Logs: sudo journalctl -u ${SERVICE_NAME} -f" +echo "健康检查 / Health: curl -s http://127.0.0.1:8000/health" diff --git a/scripts/diagnose_camera.py b/scripts/diagnose_camera.py new file mode 100644 index 0000000..bc7c8a7 --- /dev/null +++ b/scripts/diagnose_camera.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +""" +相机诊断脚本 +用于检查相机初始化、启动和运行状态 +""" +import asyncio +import httpx +import json +import sys +from pathlib import Path + +BASE_URL = "http://localhost:8000/api/debug/camera" + + +async def check_camera_status(client): + """检查相机状态 / Check camera status""" + print("🔍 检查相机状态...") + try: + response = await client.get(f"{BASE_URL}/status") + response.raise_for_status() + result = response.json() + + print(f"✅ 相机状态: {json.dumps(result, indent=2, ensure_ascii=False)}") + + if not result.get("connected", False): + print("❌ 相机未连接") + return False + + if not result.get("streaming", False): + print("⚠️ 相机未在流式传输") + return False + + print("✅ 相机状态正常") + return True + + except httpx.HTTPStatusError as e: + print(f"❌ HTTP错误: {e.response.status_code} - {e.response.text}") + return False + except httpx.RequestError as e: + print(f"❌ 请求错误: {e}") + return False + except Exception as e: + print(f"❌ 意外错误: {e}") + return False + + +async def start_camera(client): + """启动相机 / Start camera""" + print("\n🚀 尝试启动相机...") + try: + response = await client.post(f"{BASE_URL}/start") + response.raise_for_status() + result = response.json() + + print(f"✅ 相机启动结果: {json.dumps(result, indent=2, ensure_ascii=False)}") + return result.get("success", False) + + except httpx.HTTPStatusError as e: + print(f"❌ 启动失败 - HTTP错误: {e.response.status_code} - {e.response.text}") + return False + except httpx.RequestError as e: + print(f"❌ 启动失败 - 请求错误: {e}") + return False + except Exception as e: + print(f"❌ 启动失败 - 意外错误: {e}") + return False + + +async def test_preview(client): + """测试预览功能 / Test preview functionality""" + print("\n📷 测试预览功能...") + try: + response = await client.get(f"{BASE_URL}/preview") + response.raise_for_status() + + content_type = response.headers.get("content-type", "") + content_length = len(response.content) + + print(f"✅ 预览响应:") + print(f" - Content-Type: {content_type}") + print(f" - Content-Length: {content_length} bytes") + + if content_type.startswith("image/"): + print("✅ 预览图像正常") + return True + else: + print("❌ 预览不是图像格式") + return False + + except httpx.HTTPStatusError as e: + print(f"❌ 预览失败 - HTTP错误: {e.response.status_code} - {e.response.text}") + return False + except httpx.RequestError as e: + print(f"❌ 预览失败 - 请求错误: {e}") + return False + except Exception as e: + print(f"❌ 预览失败 - 意外错误: {e}") + return False + + +async def test_histogram(client): + """测试直方图功能 / Test the histogram function""" + print("\n📈 测试直方图功能...") + try: + response = await client.get(f"{BASE_URL}/image-histogram") + response.raise_for_status() + result = response.json() + + if result.get("success"): + histogram_data = result.get("histogram", {}) + print("✅ 直方图数据获取成功") + + if "error" in histogram_data: + print(f"⚠️ 直方图错误: {histogram_data['error']}") + return False + + if "histogram" in histogram_data and histogram_data["histogram"]: + print(f" - 灰度直方图数据点: {len(histogram_data['histogram'])}") + + if "statistics" in histogram_data: + stats = histogram_data["statistics"] + print(f" - 平均亮度: {stats.get('mean_brightness', 'N/A')}") + print(f" - 暗部像素: {stats.get('dark_pixels_percent', 'N/A')}%") + + return True + else: + print(f"❌ 直方图获取失败: {result}") + return False + + except Exception as e: + print(f"❌ 直方图测试失败: {e}") + return False + + +async def check_system_dependencies(): + """检查系统依赖 / Check system dependencies""" + print("\n🔧 检查系统依赖...") + + # 检查 Picamera2 / Check Picamera2 + try: + import picamera2 + + print("✅ Picamera2 已安装") + except ImportError: + print("❌ Picamera2 未安装") + print(" 请运行: sudo apt install python3-picamera2") + return False + + # 检查 OpenCV / Check OpenCV + try: + import cv2 + + print("✅ OpenCV 已安装") + except ImportError: + print("⚠️ OpenCV 未安装 (直方图功能需要)") + print(" 请运行: sudo apt install python3-opencv") + print(" 或: pip install opencv-python-headless") + + # 检查 NumPy / Check NumPy + try: + import numpy + + print("✅ NumPy 已安装") + except ImportError: + print("❌ NumPy 未安装") + return False + + return True + + +async def check_camera_hardware(): + """检查相机硬件 / Check camera hardware""" + print("\n📱 检查相机硬件...") + + # 检查相机设备 / Check camera equipment + camera_devices = ["/dev/video0", "/dev/video1", "/dev/video2", "/dev/video3"] + + found_devices = [] + for device in camera_devices: + if Path(device).exists(): + found_devices.append(device) + + if found_devices: + print(f"✅ 找到相机设备: {', '.join(found_devices)}") + else: + print("❌ 未找到相机设备") + print(" 请检查相机连接和驱动") + return False + + # 检查 libcamera / Check libcamera + try: + import subprocess + + result = subprocess.run( + ["libcamera-hello", "--list-cameras"], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + print("✅ libcamera 可用") + if result.stdout: + print(f" 检测到的相机: {result.stdout.strip()}") + else: + print("⚠️ libcamera-hello 命令失败") + except Exception as e: + print(f"⚠️ 无法检查 libcamera: {e}") + + return True + + +async def main(): + """主诊断函数 / Main diagnostic function""" + print("🔍 OGScope 相机诊断工具") + print("=" * 50) + + # 检查系统依赖 / Check system dependencies + deps_ok = await check_system_dependencies() + + # 检查相机硬件 / Check camera hardware + hw_ok = await check_camera_hardware() + + if not deps_ok or not hw_ok: + print("\n❌ 系统检查失败,请先解决依赖问题") + sys.exit(1) + + # 检查服务状态 / Check service status + async with httpx.AsyncClient(timeout=30.0) as client: + # 检查相机状态 / Check camera status + status_ok = await check_camera_status(client) + + if not status_ok: + # 尝试启动相机 / Try launching the camera + start_ok = await start_camera(client) + + if start_ok: + # 重新检查状态 / Recheck status + status_ok = await check_camera_status(client) + + if status_ok: + # 测试预览 / Test preview + preview_ok = await test_preview(client) + + # 测试直方图 / Test histogram + histogram_ok = await test_histogram(client) + + print("\n🎉 诊断完成!") + print(f"相机状态: {'✅ 正常' if status_ok else '❌ 异常'}") + print(f"预览功能: {'✅ 正常' if preview_ok else '❌ 异常'}") + print(f"直方图功能: {'✅ 正常' if histogram_ok else '❌ 异常'}") + + if status_ok and preview_ok: + print("\n✅ 相机系统运行正常!") + if histogram_ok: + print("✅ 直方图功能正常!") + else: + print("⚠️ 直方图功能异常,可能需要安装 OpenCV") + else: + print("\n❌ 相机系统存在问题,请检查日志") + else: + print("\n❌ 相机无法启动,请检查:") + print("1. 相机硬件连接") + print("2. 系统服务状态: sudo systemctl status ogscope") + print("3. 服务日志: sudo journalctl -u ogscope -f") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/scripts/ensure-ogscope-systemd-network-env.sh b/scripts/ensure-ogscope-systemd-network-env.sh new file mode 100755 index 0000000..dbcc878 --- /dev/null +++ b/scripts/ensure-ogscope-systemd-network-env.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 为已存在 network.env 的老部署补 systemd drop-in,使 ogscope 加载 OGSCOPE_* 变量 +# Install systemd drop-in for existing deployments so ogscope loads network.env +# +# 用法 / Usage: +# sudo ./scripts/ensure-ogscope-systemd-network-env.sh +# +# 等价于 / Equivalent: sudo ./scripts/ogscope-network-init.sh ensure-systemd + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [[ "${EUID}" -ne 0 ]]; then + exec sudo "${SCRIPT_DIR}/ogscope-network-init.sh" ensure-systemd +fi +exec "${SCRIPT_DIR}/ogscope-network-init.sh" ensure-systemd diff --git a/scripts/install.sh b/scripts/install.sh index 72dd0cd..1dc0ba3 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,132 +1,309 @@ #!/bin/bash -# OGScope 安装脚本 -# 适用于 Raspberry Pi Zero 2W (Raspberry Pi OS) +# OGScope 安装脚本 / OGScope installation script +# 适用于 Raspberry Pi Zero 2W 等嵌入式板 / For Raspberry Pi Zero 2W, etc. +# +# 环境变量 / Environment: +# OGSCOPE_INSTALL_DEV=1 — 安装含 dev 依赖(开发机);默认仅 main / Install dev deps; default main only +# OGSCOPE_APT_SLOW=0|1|未设置 — 未设置且内存≤1GB 时自动开;1 强制开;0 强制关 / Auto on if RAM≤1GB; 1=force; 0=disable +# OGSCOPE_SKIP_OPENCV_APT=1 — 不 apt 安装 libopencv-dev(减轻 OOM;OpenCV 由 pip opencv-python-headless 提供)/ Skip libopencv-dev to avoid OOM +# OGSCOPE_MIRROR=auto|cn|international — 软件源:auto 按语言/时区启发;中国大陆建议 cn 或保持 auto / Mirrors for CN vs abroad +# OGSCOPE_SKIP_NETWORK_BOOT=1 — 不安装开机 WiFi 引导单元 / Skip ogscope-network-boot.service +# OGSCOPE_SKIP_PLATE_DB=1 — 不自动复制 default_database.npz 到 data/plate_solve/ / Skip Tetra3 pattern DB copy +# OGSCOPE_FORCE_PLATE_DB=1 — 若目标已存在仍覆盖 / Overwrite data/plate_solve/default_database.npz if present +# OGSCOPE_POETRY_INSTALLER_URL — 可选,覆盖 Poetry 引导脚本 URL(国内可自建镜像)/ Optional Poetry bootstrap URL mirror -set -e # 遇到错误立即退出 +set -euo pipefail echo "======================================" -echo " OGScope 安装脚本" +echo " OGScope 安装脚本 / OGScope installation script" echo "======================================" -# 检查是否为 root -if [ "$EUID" -eq 0 ]; then - echo "❌ 请不要使用 root 用户运行此脚本" +if [ "${EUID}" -eq 0 ]; then + echo "❌ 请不要使用 root 用户运行此脚本 / Do not run as root" exit 1 fi -# 更新系统 -echo "📦 更新系统包..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVICE_NAME="ogscope" +SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" + +echo "📁 项目目录 / Project: ${PROJECT_DIR}" + +if [ ! -f "${PROJECT_DIR}/pyproject.toml" ]; then + echo "❌ 未找到 pyproject.toml / pyproject.toml not found" + exit 1 +fi + +cd "${PROJECT_DIR}" + +# 加载镜像逻辑(apt / PyPI)/ Load mirror helpers for apt and PyPI +# shellcheck source=mirror.sh +source "${SCRIPT_DIR}/mirror.sh" + +# 识别发行版并要求 Debian 系 + apt,避免误操作 / Detect OS; require Debian family + apt for safety +if ! ogscope_load_os_release; then + exit 1 +fi +ogscope_print_os_summary +if ! ogscope_require_debian_family_apt; then + exit 1 +fi + +OGSCOPE_MIRROR_RESOLVED="$(ogscope_resolve_mirror)" +echo "🌐 镜像模式 / Mirror: ${OGSCOPE_MIRROR_RESOLVED}(OGSCOPE_MIRROR=${OGSCOPE_MIRROR:-auto})" + +if [ "${OGSCOPE_MIRROR_RESOLVED}" = "cn" ]; then + ogscope_apply_apt_mirror_cn +fi + +# 低配板在 apt 批次间暂停,减轻 OOM(apt/dpkg 解压时)/ Pause between apt batches to reduce OOM risk +_mem_kb="$(grep '^MemTotal:' /proc/meminfo 2>/dev/null | awk '{print $2}' || echo 9999999)" +if [ "${OGSCOPE_APT_SLOW:-}" = "1" ]; then + _apt_effective_slow="1" +elif [ "${OGSCOPE_APT_SLOW:-}" = "0" ]; then + _apt_effective_slow="0" +else + if [ "${_mem_kb}" -lt 1048576 ]; then + _apt_effective_slow="1" + echo "ℹ️ MemTotal≈$((_mem_kb / 1024)) MiB — 已自动启用 apt 分批间隔(关闭:OGSCOPE_APT_SLOW=0)/ Auto staggered apt (disable: OGSCOPE_APT_SLOW=0)" + else + _apt_effective_slow="0" + fi +fi + +_apt_pause() { + if [ "${_apt_effective_slow}" = "1" ]; then + echo "⏳ 等待 4s 释放内存... / Waiting to free memory..." + sleep 4 + fi +} + +echo "📦 apt update..." sudo apt update -sudo apt upgrade -y +_apt_pause -# 安装系统依赖 -echo "📦 安装系统依赖..." +echo "📦 安装基础系统包 / Installing base packages..." sudo apt install -y \ python3 \ python3-pip \ python3-venv \ python3-dev \ git \ + curl \ build-essential \ - libopencv-dev \ + network-manager \ + avahi-daemon +_apt_pause + +echo "📦 安装图像基础库(jpeg/png/freetype)/ Installing image base dev libraries..." +sudo apt install -y \ libjpeg-dev \ libpng-dev \ - libfreetype6-dev \ - libatlas-base-dev \ - libspidev-dev \ - python3-picamera2 \ - python3-numpy - -# 安装 Poetry -if ! command -v poetry &> /dev/null; then - echo "📦 安装 Poetry..." - curl -sSL https://install.python-poetry.org | python3 - - echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc - export PATH="$HOME/.local/bin:$PATH" + libfreetype6-dev +_apt_pause + +if [ "${OGSCOPE_SKIP_OPENCV_APT:-}" = "1" ]; then + echo "ℹ️ 已跳过 apt 安装 libopencv-dev(OGSCOPE_SKIP_OPENCV_APT=1);OpenCV 由 pip opencv-python-headless 提供 / Skipped libopencv-dev; pip provides OpenCV" else - echo "✅ Poetry 已安装" + echo "📦 安装 libopencv-dev(依赖多;若进程被 Killed 多为 OOM,可重试并加 OGSCOPE_SKIP_OPENCV_APT=1 或 swap)" + echo "📦 Installing libopencv-dev (--no-install-recommends); if Killed (OOM), retry with OGSCOPE_SKIP_OPENCV_APT=1 or add swap" + sudo apt install -y --no-install-recommends libopencv-dev fi +_apt_pause -# 验证 Poetry 安装 -poetry --version || { - echo "❌ Poetry 安装失败" - exit 1 -} +# 树莓派常见;若无此包可忽略 / Common on Raspberry Pi OS; skip if unavailable +if apt-cache show python3-picamera2 >/dev/null 2>&1; then + echo "📦 安装 python3-picamera2..." + sudo apt install -y python3-picamera2 || echo "⚠️ picamera2 安装跳过 / picamera2 install skipped" +else + echo "ℹ️ 未找到 python3-picamera2 软件包,请按板卡文档安装相机栈 / No python3-picamera2 package" +fi +_apt_pause + +PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')" +echo "🐍 当前 python3 版本 / python3 version: ${PY_VER}" +echo "ℹ️ 项目要求 Python ^3.10(见 pyproject.toml)" + +if ! command -v poetry >/dev/null 2>&1; then + echo "📦 安装 Poetry(官方引导脚本;与 PEP 668 兼容)..." + echo "📦 Installing Poetry via official installer (PEP 668–safe)..." + # 国内外统一用官方脚本,避免在系统 Python 上 pip install poetry 触发 PEP 668 + # Same official bootstrap everywhere; avoids pip install poetry on managed system Python + _poetry_installer="${OGSCOPE_POETRY_INSTALLER_URL:-https://install.python-poetry.org}" + curl -sSL --retry 3 --connect-timeout 30 "${_poetry_installer}" | python3 - +fi + +export PATH="${HOME}/.local/bin:${PATH}" +if ! grep -q 'export PATH="$HOME/.local/bin:$PATH"' "${HOME}/.bashrc" 2>/dev/null; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "${HOME}/.bashrc" +fi -# 启用树莓派相机接口 -echo "📷 启用树莓派相机接口..." -sudo raspi-config nonint do_camera 0 +poetry --version >/dev/null +echo "✅ Poetry: $(poetry --version)" -# 创建项目目录 -INSTALL_DIR="$HOME/OGScope" -if [ ! -d "$INSTALL_DIR" ]; then - echo "📁 创建项目目录: $INSTALL_DIR" - mkdir -p "$INSTALL_DIR" +# 强制使用项目虚拟环境,避免 PEP 668 与系统混装 / Force project venv (avoids PEP 668) +echo "⚙️ 配置 Poetry 虚拟环境 / Configuring Poetry virtualenvs..." +poetry config virtualenvs.create true +poetry config virtualenvs.in-project true +if poetry config virtualenvs.options.system-site-packages true 2>/dev/null; then + echo "✅ virtualenvs.options.system-site-packages = true(可与系统 picamera2 共存 / can see system picamera2)" else - echo "✅ 项目目录已存在: $INSTALL_DIR" + echo "⚠️ 当前 Poetry 可能不支持 system-site-packages,将仅依赖 PYTHONPATH / Poetry may lack system-site-packages; using PYTHONPATH only" fi -cd "$INSTALL_DIR" +INSTALL_ARGS=(install --no-interaction) +if [ "${OGSCOPE_INSTALL_DEV:-}" = "1" ]; then + echo "📦 poetry install(含 dev)..." +else + INSTALL_ARGS+=(--only main) + echo "📦 poetry install --only main(生产默认;设 OGSCOPE_INSTALL_DEV=1 可装 dev)..." +fi -# 克隆或更新代码(如果是从 GitHub 安装) -if [ -d ".git" ]; then - echo "🔄 更新代码..." - git pull +# 低配板:限制并行 wheel 安装数,减轻峰值内存 / Limit parallel installs on low-RAM boards +export POETRY_INSTALLER_MAX_WORKERS="${POETRY_INSTALLER_MAX_WORKERS:-2}" + +if [ "${OGSCOPE_MIRROR_RESOLVED}" = "cn" ]; then + ogscope_export_pypi_mirror_cn else - echo "⚠️ 请手动克隆代码或复制文件到 $INSTALL_DIR" + ogscope_export_pypi_mirror_international +fi + +poetry "${INSTALL_ARGS[@]}" + +# numpy/scipy 与 lock 一致;Poetry 偶发「无更新」但 wheel 未落盘 / Align deps with lock; retry if missing +if ! ogscope_verify_numpy_scipy; then + echo "⚠️ numpy/scipy 导入失败,使用 --no-cache 重试 poetry install / Import failed; retrying poetry with --no-cache" + poetry "${INSTALL_ARGS[@]}" --no-cache +fi +if ! ogscope_verify_numpy_scipy; then + echo "⚠️ 仍缺少 scipy,使用 pip 补装(与 pyproject 版本约束一致)/ scipy still missing; pip install (same constraints)" + poetry run pip install --no-cache-dir "scipy>=1.10,<1.17" +fi +if ! ogscope_verify_numpy_scipy; then + echo "❌ numpy/scipy 仍不可用。请删除 .venv 后重试: rm -rf .venv && ./scripts/install.sh" + echo "❌ Still failing. Try: rm -rf .venv && ./scripts/install.sh" + exit 1 +fi +echo "✅ numpy/scipy 已就绪 / numpy & scipy OK" + +VENV_PATH="$(poetry env info --path)" +VENV_PYTHON="${VENV_PATH}/bin/python" +if [ ! -x "${VENV_PYTHON}" ]; then + echo "❌ 未找到虚拟环境解释器 / venv python missing: ${VENV_PYTHON}" + exit 1 fi -# 安装 Python 依赖 -echo "📦 安装 Python 依赖..." -poetry install --no-interaction --no-root +echo "📁 创建数据目录 / Creating data directories..." +mkdir -p logs data uploads data/plate_solve data/analysis -# 创建必要的目录 -echo "📁 创建数据目录..." -mkdir -p logs data uploads +ogscope_sync_plate_solve_database_if_needed "${PROJECT_DIR}" -# 创建配置文件 -if [ ! -f "config.json" ]; then - echo "⚙️ 创建配置文件..." - cp default_config.json config.json - echo "⚠️ 请编辑 config.json 修改配置" +# systemd 注入 PYTHONPATH,便于 venv 内 import apt 安装的包 / PYTHONPATH for apt-installed packages in venv +PY_PATHS=() +[ -d "/usr/lib/python3/dist-packages" ] && PY_PATHS+=("/usr/lib/python3/dist-packages") +# 动态加入 /usr/local/lib/pythonX.Y/dist-packages(若存在)/ Add /usr/local dist-packages if present +for _py in 13 12 11 10; do + _d="/usr/local/lib/python3.${_py}/dist-packages" + [ -d "${_d}" ] && PY_PATHS+=("${_d}") +done + +PYTHONPATH_VALUE="$(IFS=:; echo "${PY_PATHS[*]}")" +[ -z "${PYTHONPATH_VALUE}" ] && PYTHONPATH_VALUE="/usr/lib/python3/dist-packages" + +# libcamera 等动态库路径(按架构探测)/ Dynamic linker paths for libcamera etc. (arch-detected) +LD_PARTS=() +for _ld in /usr/lib/aarch64-linux-gnu /usr/lib/arm-linux-gnueabihf; do + [ -d "${_ld}" ] && LD_PARTS+=("${_ld}") +done +LD_LIBRARY_PATH_VALUE="$(IFS=:; echo "${LD_PARTS[*]}")" +if [ -z "${LD_LIBRARY_PATH_VALUE}" ]; then + LD_LIBRARY_PATH_VALUE="/usr/lib/aarch64-linux-gnu" + echo "⚠️ 未检测到标准库目录,使用默认 ${LD_LIBRARY_PATH_VALUE} / No lib dir found; using default aarch64 path" fi -# 配置 systemd 服务 -echo "⚙️ 配置系统服务..." -sudo tee /etc/systemd/system/ogscope.service > /dev/null </dev/null </dev/null || true + echo "🌐 运行网络初始化(需 sudo)/ Running network bootstrap..." + sudo env OGSCOPE_SERVICE_USER="${USER}" "${SCRIPT_DIR}/ogscope-network-init.sh" init --yes \ + || echo "⚠️ 网络初始化失败,可稍后 sudo ${SCRIPT_DIR}/ogscope-network-init.sh diag / network init failed" +fi + +BOOT_UNIT="ogscope-network-boot" +BOOT_UNIT_PATH="/etc/systemd/system/${BOOT_UNIT}.service" +if [ "${OGSCOPE_SKIP_NETWORK_BOOT:-}" = "1" ]; then + echo "⏭️ 跳过开机网络引导单元(OGSCOPE_SKIP_NETWORK_BOOT=1)/ Skipping ${BOOT_UNIT}.service" +else + chmod +x "${SCRIPT_DIR}/ogscope-network-boot.sh" 2>/dev/null || true + echo "⚙️ 写入开机 WiFi 引导(root oneshot)/ Writing ${BOOT_UNIT}.service..." + sudo tee "${BOOT_UNIT_PATH}" >/dev/null </dev/null || true +fi echo "" echo "======================================" -echo " ✅ 安装完成!" +echo " ✅ 安装完成 / Installation done" echo "======================================" +echo "服务 / Service: ${SERVICE_NAME}" +echo "虚拟环境 / venv: ${VENV_PATH}" +echo "PYTHONPATH: ${PYTHONPATH_VALUE}" +echo "LD_LIBRARY_PATH: ${LD_LIBRARY_PATH_VALUE}" echo "" -echo "下一步:" -echo "1. 编辑配置: nano $INSTALL_DIR/config.json" -echo "2. 启动服务: sudo systemctl start ogscope" -echo "3. 查看状态: sudo systemctl status ogscope" -echo "4. 查看日志: journalctl -u ogscope -f" -echo "5. 访问 Web: http://$(hostname -I | awk '{print $1}'):8000" +ogscope_report_plate_solve_database_status "${PROJECT_DIR}" +echo "" +echo "下一步 / Next:" +echo " sudo systemctl start ${SERVICE_NAME}" +echo " sudo systemctl status ${SERVICE_NAME}" +echo " sudo journalctl -u ${SERVICE_NAME} -f" +if [ "${OGSCOPE_SKIP_NETWORK_BOOT:-}" != "1" ]; then + echo " 开机 WiFi 引导日志 / Boot WiFi: sudo journalctl -u ${BOOT_UNIT} -b" +fi +echo " 日常更新可运行: ./scripts/board-update.sh" echo "" - diff --git a/scripts/mirror.sh b/scripts/mirror.sh new file mode 100644 index 0000000..9ba3033 --- /dev/null +++ b/scripts/mirror.sh @@ -0,0 +1,384 @@ +# OGScope 安装镜像辅助 / Mirror helpers for OGScope install scripts +# 由 install.sh / board-update.sh 用 `source` 加载 / Sourced by install.sh and board-update.sh +# +# 说明:Poetry 本体仍由官方 install.python-poetry.org 引导安装(避免 PEP 668); +# cn 模式主要加速 apt 与「项目依赖」的 PyPI 下载 / Poetry bootstrap stays official; cn speeds apt + project wheels +# +# 环境变量 / Environment: +# OGSCOPE_MIRROR=auto|cn|international +# auto — 根据 LANG/LC_* 与系统时区启发式判断(可误判,可显式覆盖) +# cn — 使用中国大陆常用镜像(apt + PyPI) +# international — 使用系统默认与官方 PyPI,不替换 apt 源 +# +# 启发式说明 / Heuristic: 非中文环境用户若在国内,请显式设置 OGSCOPE_MIRROR=cn +# / Users in China with English locale should set OGSCOPE_MIRROR=cn explicitly. + +# 从 /etc/os-release 加载发行版信息(供检测与安全判断)/ Load distro info from /etc/os-release +# 导出 OGSCOPE_OS_* / Exports OGSCOPE_OS_ID, VERSION_ID, PRETTY_NAME, ID_LIKE, VARIANT, etc. +ogscope_load_os_release() { + if [ ! -r /etc/os-release ]; then + echo "❌ 未找到 /etc/os-release,无法识别发行版 / Missing /etc/os-release; cannot detect OS" >&2 + return 1 + fi + # shellcheck disable=SC1091 + . /etc/os-release + export OGSCOPE_OS_ID="${ID:-unknown}" + export OGSCOPE_OS_VERSION_ID="${VERSION_ID:-}" + export OGSCOPE_OS_VERSION_CODENAME="${VERSION_CODENAME:-}" + export OGSCOPE_OS_PRETTY_NAME="${PRETTY_NAME:-}" + export OGSCOPE_OS_ID_LIKE="${ID_LIKE:-}" + export OGSCOPE_OS_VARIANT="${VARIANT:-}" + export OGSCOPE_OS_VARIANT_ID="${VARIANT_ID:-}" + return 0 +} + +# 是否为 apt + Debian 系(含 Raspberry Pi OS、Ubuntu、Armbian 等)/ True if apt-based Debian family +# Raspberry Pi OS 通常 ID=debian;旧版可能为 raspbian / RPi OS is usually ID=debian; older may be raspbian +ogscope_is_debian_family() { + case "${OGSCOPE_OS_ID:-}" in + debian | ubuntu | raspbian | linuxmint | pop | zorin | kali) + return 0 + ;; + esac + case ",${OGSCOPE_OS_ID_LIKE:-}," in + *,debian,*) return 0 ;; + *,ubuntu,*) return 0 ;; + esac + return 1 +} + +# 安装脚本入口:非 Debian 系则退出,避免误改软件源 / Abort install on non-Debian systems (safety) +ogscope_require_debian_family_apt() { + if ! ogscope_is_debian_family; then + echo "❌ 本脚本仅支持 Debian/Ubuntu 系发行版(含 Raspberry Pi OS)。" >&2 + echo "❌ This installer only supports Debian/Ubuntu family (incl. Raspberry Pi OS, Armbian Debian)." >&2 + echo " 当前 ID=${OGSCOPE_OS_ID:-?} ID_LIKE=${OGSCOPE_OS_ID_LIKE:-?} / Current OS ID shown above." >&2 + return 1 + fi + if ! command -v apt >/dev/null 2>&1 && ! command -v apt-get >/dev/null 2>&1; then + echo "❌ 未找到 apt/apt-get / apt not found" >&2 + return 1 + fi + return 0 +} + +# 打印已识别系统(中英)/ Print detected OS (bilingual) +ogscope_print_os_summary() { + echo "🖥️ 发行版 / OS: ${OGSCOPE_OS_PRETTY_NAME:-${OGSCOPE_OS_ID:-unknown}}" + echo " ID=${OGSCOPE_OS_ID:-?} VERSION_ID=${OGSCOPE_OS_VERSION_ID:-?} CODENAME=${OGSCOPE_OS_VERSION_CODENAME:-?}" + if [ -n "${OGSCOPE_OS_VARIANT:-}" ]; then + echo " VARIANT=${OGSCOPE_OS_VARIANT:-} / VARIANT_ID=${OGSCOPE_OS_VARIANT_ID:-}" + fi + if [ -f /proc/device-tree/model ]; then + echo " 硬件型号 / Hardware: $(tr -d '\0' /dev/null || echo '?')" + fi +} + +# 解析镜像模式,标准输出为 cn 或 international / Resolve mode; prints cn or international +ogscope_resolve_mirror() { + local m="${OGSCOPE_MIRROR:-auto}" + case "${m}" in + cn | CN | china | China) + echo cn + return 0 + ;; + # 境外 / Outside mainland China (explicit) + international | global | intl | default | us | US | eu | EU) + echo international + return 0 + ;; + auto | "") + ;; + *) + echo "⚠️ 未知 OGSCOPE_MIRROR=${m},按 auto / Unknown OGSCOPE_MIRROR, using auto" >&2 + ;; + esac + + case "${LANG:-}" in *zh_CN*) echo cn && return 0 ;; esac + case "${LC_ALL:-}" in *zh_CN*) echo cn && return 0 ;; esac + case "${LC_MESSAGES:-}" in *zh_CN*) echo cn && return 0 ;; esac + + # 时区启发 / Timezone heuristic (common China zones) + local tz="" + if [ -r /etc/timezone ]; then + tz="$(tr -d '\r\n' /dev/null 2>&1; then + tz="$(timedatectl show -p Timezone --value 2>/dev/null || true)" + fi + case "${tz}" in + Asia/Shanghai | Asia/Chongqing | Asia/Harbin | Asia/Urumqi | Asia/Hong_Kong | Asia/Macau | Asia/Taipei) + echo cn + return 0 + ;; + esac + + echo international +} + +# 导出中国大陆 PyPI 环境变量(清华)/ Export env for Tsinghua PyPI mirror +ogscope_export_pypi_mirror_cn() { + export PIP_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple" + export PIP_TRUSTED_HOST="pypi.tuna.tsinghua.edu.cn" + export UV_INDEX_URL="https://pypi.tuna.tsinghua.edu.cn/simple" + # Poetry / urllib 部分场景会读 REQUESTS_*;延长超时利于弱网 / Longer timeout for slow links + export POETRY_REQUESTS_TIMEOUT="${POETRY_REQUESTS_TIMEOUT:-120}" +} + +# 取消国内 PyPI 覆盖,使用默认官方索引 / Unset CN overrides; use default PyPI +ogscope_export_pypi_mirror_international() { + unset PIP_INDEX_URL PIP_TRUSTED_HOST UV_INDEX_URL || true +} + +# 将 apt 源替换为清华镜像(需 sudo)/ Replace apt sources with Tsinghua mirror (requires sudo) +ogscope_apply_apt_mirror_cn() { + local stamp + stamp="$(date +%s)" + echo "🌏 配置 apt 使用中国大陆镜像(清华)… / Configuring apt for China mirror (Tsinghua)…" + + if [ ! -d /etc/apt ]; then + echo "⚠️ 未找到 /etc/apt,跳过 apt 镜像 / No /etc/apt, skipping" + return 0 + fi + + sudo cp -a /etc/apt/sources.list "/etc/apt/sources.list.bak.ogscope.${stamp}" 2>/dev/null || true + if [ -d /etc/apt/sources.list.d ]; then + sudo find /etc/apt/sources.list.d -maxdepth 1 -type f \( -name '*.list' -o -name '*.sources' \) -exec \ + cp -a {} {}.bak.ogscope."${stamp}" \; 2>/dev/null || true + fi + + # Ubuntu / Ubuntu ports / Debian 常见写法 / Common Ubuntu & Debian patterns + sudo find /etc/apt -type f \( -name 'sources.list' -o -name '*.list' -o -name '*.sources' \) -print0 2>/dev/null | + while IFS= read -r -d '' f; do + [ -z "${f}" ] && continue + sudo sed -i \ + -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \ + -e 's|https://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \ + -e 's|http://ports.ubuntu.com/ubuntu-ports|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports|g' \ + -e 's|https://ports.ubuntu.com/ubuntu-ports|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports|g' \ + -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \ + -e 's|https://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \ + -e 's|http://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' \ + -e 's|https://deb.debian.org/debian|https://mirrors.tuna.tsinghua.edu.cn/debian|g' \ + -e 's|http://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \ + -e 's|https://security.debian.org/debian-security|https://mirrors.tuna.tsinghua.edu.cn/debian-security|g' \ + "${f}" 2>/dev/null || true + done + + echo "✅ apt 镜像已写入(已备份 *.bak.ogscope.${stamp})/ Apt mirror applied (backups created)" +} + +# 验证 venv 中 numpy/scipy 可导入 / Verify numpy & scipy import (catches stale Poetry state) +# Poetry 有时显示「无依赖更新」但大 wheel 未实际安装 / Poetry may skip while wheels missing +ogscope_verify_numpy_scipy() { + poetry run python -c "import numpy, scipy" 2>/dev/null +} + +# 若 systemd 已存在但 ExecStart 不是当前 Poetry venv,则修正(避免 ~/.virtualenvs/ 与项目 .venv 混用) +# If unit exists but ExecStart points elsewhere than current Poetry venv, fix it (avoids ~/.virtualenvs vs .venv mismatch) +# 参数 / Args: $1 = unit 文件路径 / unit file path, $2 = venv 内 python 可执行文件绝对路径 / absolute path to venv python +ogscope_sync_systemd_execstart_if_needed() { + local unit_path="${1:?}" + local venv_python="${2:?}" + local expected_line="ExecStart=${venv_python} -m ogscope.main" + + if [ ! -f "${unit_path}" ]; then + echo "ℹ️ 未找到 ${unit_path},跳过 ExecStart 同步(请先运行 install.sh)/ No unit; skip sync (run install.sh first)" + return 0 + fi + if [ ! -x "${venv_python}" ]; then + echo "❌ 解释器不可执行 / Python not executable: ${venv_python}" >&2 + return 1 + fi + + local cur_line + cur_line="$(grep '^ExecStart=' "${unit_path}" | head -n1 || true)" + if [ -z "${cur_line}" ]; then + echo "❌ ${unit_path} 中无 ExecStart / No ExecStart in unit" >&2 + return 1 + fi + + if [ "${cur_line}" = "${expected_line}" ]; then + echo "✅ systemd ExecStart 与当前 Poetry venv 一致 / ExecStart matches Poetry venv" + return 0 + fi + + echo "⚙️ 修正 systemd ExecStart(曾指向旧虚拟环境路径)/ Fixing ExecStart (was stale venv path)" + echo " 旧 / Old: ${cur_line}" + echo " 新 / New: ${expected_line}" + sudo sed -i "s|^ExecStart=.*|${expected_line}|" "${unit_path}" + echo "✅ 已更新 ${unit_path} / Unit updated" +} + +# 若项目目录变更,同步 ogscope-network-boot.service 的 ExecStart(与 install.sh 一致) +# If project path changed, sync ExecStart in ogscope-network-boot.service (matches install.sh) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +ogscope_sync_network_boot_unit_if_needed() { + local project_dir="${1:?}" + local unit_path="/etc/systemd/system/ogscope-network-boot.service" + local script_path="${project_dir}/scripts/ogscope-network-boot.sh" + local expected_line="ExecStart=${script_path}" + + if [ ! -f "${unit_path}" ]; then + echo "ℹ️ 未找到 ${unit_path},跳过开机网络 boot 同步(未安装或已移除)/ No boot unit; skip sync" + return 0 + fi + if [ ! -f "${script_path}" ]; then + echo "⚠️ 项目内缺少 ${script_path},跳过 ExecStart 同步 / Script missing; skip ExecStart sync" >&2 + return 0 + fi + + local cur_line + cur_line="$(grep '^ExecStart=' "${unit_path}" | head -n1 || true)" + if [ -z "${cur_line}" ]; then + echo "⚠️ ${unit_path} 中无 ExecStart / No ExecStart in unit" >&2 + return 0 + fi + + if [ "${cur_line}" = "${expected_line}" ]; then + echo "✅ ogscope-network-boot ExecStart 与当前项目目录一致 / Boot unit ExecStart matches project" + return 0 + fi + + echo "⚙️ 修正 ogscope-network-boot ExecStart(项目目录可能已变更)/ Fixing boot unit ExecStart" + echo " 旧 / Old: ${cur_line}" + echo " 新 / New: ${expected_line}" + sudo sed -i "s|^ExecStart=.*|${expected_line}|" "${unit_path}" + echo "✅ 已更新 ${unit_path} / Unit updated" +} + +# 解析 vendored tetra3 内 default_database.npz 路径(不依赖 import ogscope,避免路径注入异常) +# Resolve path to default_database.npz in vendored tetra3 (no import ogscope; avoids path edge cases) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# 标准输出:若存在则打印绝对路径,否则空 / Prints absolute path if file exists, else empty +ogscope_resolve_plate_solve_database_src_from_venv() { + local project_dir="${1:?}" + ( + cd "${project_dir}" && PROJECT_ROOT="${project_dir}" poetry run python - <<'PY' +import os +import sys +from pathlib import Path + +project = Path(os.environ["PROJECT_ROOT"]).resolve() +vendor = project / "ogscope" / "vendor" +if vendor.is_dir(): + sys.path.insert(0, str(vendor)) +import tetra3 # noqa: E402 — after vendor path + +p = Path(tetra3.__file__).resolve().parent / "data" / "default_database.npz" +print(p if p.is_file() else "", end="") +PY + ) +} + +# 将 default_database.npz 复制到 data/plate_solve/(与 solver 优先路径一致) +# Copy default_database.npz into data/plate_solve/ (matches solver resolution order) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# 环境 / Env: OGSCOPE_SKIP_PLATE_DB=1 跳过;OGSCOPE_FORCE_PLATE_DB=1 覆盖已存在目标 / skip; overwrite dest +ogscope_sync_plate_solve_database_if_needed() { + local project_dir="${1:?}" + local dest="${project_dir}/data/plate_solve/default_database.npz" + local vendor_src="${project_dir}/ogscope/vendor/tetra3/data/default_database.npz" + + if [ "${OGSCOPE_SKIP_PLATE_DB:-}" = "1" ]; then + echo "⏭️ 跳过图案库复制(OGSCOPE_SKIP_PLATE_DB=1)/ Skipping plate DB copy" + return 0 + fi + + mkdir -p "${project_dir}/data/plate_solve" + + if [ -f "${dest}" ] && [ "${OGSCOPE_FORCE_PLATE_DB:-}" != "1" ]; then + echo "ℹ️ 已存在 ${dest},跳过复制(覆盖:OGSCOPE_FORCE_PLATE_DB=1)/ Already present; skip (overwrite: OGSCOPE_FORCE_PLATE_DB=1)" + return 0 + fi + + local src="" + if [ -f "${vendor_src}" ]; then + src="${vendor_src}" + else + src="$(ogscope_resolve_plate_solve_database_src_from_venv "${project_dir}" || true)" + fi + + if [ -z "${src}" ] || [ ! -f "${src}" ]; then + echo "⚠️ 未找到可复制的 default_database.npz(请放入 ogscope/vendor/tetra3/data/ 或手动复制到 data/plate_solve/)" + echo "⚠️ No default_database.npz to copy; see docs/development/plate-solve-data.md" + return 0 + fi + + echo "📋 复制 Tetra3 图案库到 data/plate_solve/default_database.npz ..." + echo "📋 Copying Tetra3 pattern database to data/plate_solve/default_database.npz ..." + cp -a "${src}" "${dest}" + echo "✅ 图案库已就绪 / Pattern database ready: ${dest}" +} + +# 安装/升级后校验 data/plate_solve/default_database.npz 是否存在(主动检查) +# Verify default_database.npz after install/update (explicit check) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +ogscope_report_plate_solve_database_status() { + local project_dir="${1:?}" + local dest="${project_dir}/data/plate_solve/default_database.npz" + + if [ "${OGSCOPE_SKIP_PLATE_DB:-}" = "1" ]; then + echo "ℹ️ 已跳过图案库步骤(OGSCOPE_SKIP_PLATE_DB=1);未校验 ${dest} / Skipped plate DB step" + return 0 + fi + + if [ -f "${dest}" ]; then + local sz + sz="$(du -h "${dest}" 2>/dev/null | awk '{print $1}' || echo "?")" + echo "✅ 星图解算图案库 / Plate DB: data/plate_solve/default_database.npz(${sz})" + echo "✅ Plate DB: data/plate_solve/default_database.npz (${sz})" + return 0 + fi + + echo "⚠️ 星图解算需 default_database.npz,但未在 data/plate_solve/ 找到。" + echo " 请将文件放入 ogscope/vendor/tetra3/data/ 后重装/重跑本脚本,或手动复制到 data/plate_solve/;见 docs/development/plate-solve-data.md" + echo "⚠️ Plate solving needs default_database.npz under data/plate_solve/; see docs/development/plate-solve-data.md" +} + +# 增量更新:同步网络相关工件(与近期 wifi-nm / systemd 文档一致) +# Board update: sync network artifacts (matches wifi-nm + systemd docs) +# 参数 / Args: $1 = 项目根目录绝对路径 / absolute project root +# 环境 / Env: OGSCOPE_SKIP_NETWORK_SYNC=1 跳过;需 sudo(免密或交互)/ skip; requires sudo +ogscope_sync_network_board_artifacts_if_needed() { + local project_dir="${1:?}" + local init_script="${project_dir}/scripts/ogscope-network-init.sh" + local switch_src="${project_dir}/scripts/ogscope-wifi-switch.sh" + local switch_dst="/usr/local/bin/ogscope-wifi-switch" + + if [ "${OGSCOPE_SKIP_NETWORK_SYNC:-}" = "1" ]; then + echo "⏭️ 跳过网络工件同步(OGSCOPE_SKIP_NETWORK_SYNC=1)/ Skipping network artifact sync" + return 0 + fi + + chmod +x "${project_dir}/scripts/ogscope-network-boot.sh" 2>/dev/null || true + chmod +x "${init_script}" 2>/dev/null || true + + if [ -f "${switch_src}" ]; then + if [ ! -f "${switch_dst}" ] || ! cmp -s "${switch_src}" "${switch_dst}" 2>/dev/null; then + echo "📋 同步 WiFi 切换脚本 / Syncing WiFi switch script → ${switch_dst} ..." + if sudo -n true 2>/dev/null; then + sudo install -m 755 "${switch_src}" "${switch_dst}" + echo "✅ 已更新 ${switch_dst} / Updated" + else + echo "⚠️ 无法免密 sudo,未更新 ${switch_dst};请手动: sudo install -m 755 ${switch_src} ${switch_dst}" + echo "⚠️ Non-interactive sudo unavailable; install switch script manually (see wifi-nm.md)" + fi + else + echo "✅ ogscope-wifi-switch 已是最新 / WiFi switch script up to date" + fi + fi + + if [ ! -f "/etc/ogscope/network.env" ]; then + echo "ℹ️ 无 /etc/ogscope/network.env,跳过 ensure-systemd(首次部署请运行 install.sh)/ No network.env; skip ensure-systemd" + return 0 + fi + + echo "🌐 同步 systemd network.env 与 nmcli sudoers(ensure-systemd)/ Syncing ensure-systemd ..." + if sudo -n true 2>/dev/null; then + sudo env OGSCOPE_SERVICE_USER="${USER}" "${init_script}" ensure-systemd \ + || echo "⚠️ ensure-systemd 失败;可手动: sudo env OGSCOPE_SERVICE_USER=\$USER ${init_script} ensure-systemd" + else + echo "⚠️ 无法免密 sudo,未运行 ensure-systemd;若 Web WiFi 异常请手动执行上述命令(见 docs/development/wifi-nm.md)" + echo "⚠️ Non-interactive sudo unavailable; run ensure-systemd manually if WiFi/API issues" + fi +} diff --git a/scripts/ogscope-network-boot.sh b/scripts/ogscope-network-boot.sh new file mode 100755 index 0000000..d9d57af --- /dev/null +++ b/scripts/ogscope-network-boot.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +# OGScope 开机无线引导:等待 STA 获 IPv4,失败则切 AP(root / systemd oneshot) +# Boot WiFi: wait for STA IPv4, else bring up AP (root, independent of ogscope.service) +# +# 环境变量 / Environment (optional; defaults match ogscope-network-init): +# OGSCOPE_WIFI_STA_CONNECTION — STA 连接名 / STA profile name +# OGSCOPE_WIFI_AP_CONNECTION — AP 连接名 / AP profile name +# OGSCOPE_WIFI_INTERFACE — 无线接口 / Wireless iface (default wlan0) +# OGSCOPE_BOOT_STA_WAIT_SEC — 首轮等待 STA 获 IPv4 的总秒数 / First wait for IPv4 (default 55) +# OGSCOPE_BOOT_POLL_SEC — 轮询间隔 / Poll interval (default 3) +# OGSCOPE_BOOT_STA_UP_RETRIES — 尝试 connection up STA 次数 / nmcli up STA retries (default 2) +# OGSCOPE_BOOT_POST_UP_WAIT — 每次 up STA 后再等待秒数 / Wait after each up (default 20) + +set -euo pipefail + +ENV_FILE="/etc/ogscope/network.env" +if [[ -r "${ENV_FILE}" ]]; then + set -a + # shellcheck disable=SC1090 + source "${ENV_FILE}" + set +a +fi + +STA="${OGSCOPE_WIFI_STA_CONNECTION:-OGScope-STA}" +AP="${OGSCOPE_WIFI_AP_CONNECTION:-OGScope-AP}" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" +WAIT_TOTAL="${OGSCOPE_BOOT_STA_WAIT_SEC:-55}" +POLL="${OGSCOPE_BOOT_POLL_SEC:-3}" +RETRIES="${OGSCOPE_BOOT_STA_UP_RETRIES:-2}" +POST_UP="${OGSCOPE_BOOT_POST_UP_WAIT:-20}" + +log() { logger -t ogscope-network-boot -- "$@" || true; echo "[ogscope-network-boot] $*"; } + +has_usable_ipv4() { + local iface="$1" + local line ip + while IFS= read -r line; do + [[ -z "${line}" || "${line}" == "--" ]] && continue + ip="${line%%/*}" + if [[ "${ip}" =~ ^169\.254\. ]]; then + continue + fi + if [[ "${ip}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + return 0 + fi + done < <(nmcli -g IP4.ADDRESS device show "${iface}" 2>/dev/null || true) + return 1 +} + +wait_iface_and_nmcli() { + local n=0 + while [[ ! -r "/sys/class/net/${IFACE}/address" ]]; do + n=$((n + 1)) + if [[ "${n}" -gt 40 ]]; then + log "timeout waiting for ${IFACE}" + return 1 + fi + sleep 0.5 + done + n=0 + while ! command -v nmcli >/dev/null 2>&1; do + n=$((n + 1)) + if [[ "${n}" -gt 20 ]]; then + log "nmcli not found" + return 1 + fi + sleep 0.5 + done + return 0 +} + +skip_if_other_default_route() { + # 已有默认路由且不在无线口上则跳过(有线已联网)/ Skip if default route is not on wifi + if ip -4 route show default 2>/dev/null | grep -q "dev ${IFACE}"; then + return 1 + fi + if ip -4 route show default 2>/dev/null | grep -q .; then + log "skip: default IPv4 route uses another interface (not ${IFACE})" + return 0 + fi + return 1 +} + +main() { + if ! wait_iface_and_nmcli; then + log "iface/nmcli unavailable; exit" + exit 1 + fi + + if skip_if_other_default_route; then + exit 0 + fi + + local elapsed=0 + while [[ "${elapsed}" -lt "${WAIT_TOTAL}" ]]; do + if has_usable_ipv4 "${IFACE}"; then + log "STA has usable IPv4 on ${IFACE}, done" + exit 0 + fi + sleep "${POLL}" + elapsed=$((elapsed + POLL)) + done + + log "no IPv4 after ${WAIT_TOTAL}s, trying nmcli connection up ${STA}" + local r=0 + while [[ "${r}" -lt "${RETRIES}" ]]; do + r=$((r + 1)) + nmcli connection up "${STA}" ifname "${IFACE}" 2>/dev/null || log "nmcli up ${STA} attempt ${r} failed" + sleep "${POST_UP}" + if has_usable_ipv4 "${IFACE}"; then + log "STA connected after up retry ${r}" + exit 0 + fi + done + + log "fallback: switching to AP ${AP}" + nmcli connection down "${AP}" 2>/dev/null || true + nmcli connection down "${STA}" 2>/dev/null || true + nmcli connection up "${AP}" ifname "${IFACE}" || { + log "failed to bring up AP ${AP}" + exit 1 + } + log "AP ${AP} is up" + exit 0 +} + +main "$@" diff --git a/scripts/ogscope-network-init.sh b/scripts/ogscope-network-init.sh new file mode 100755 index 0000000..478e424 --- /dev/null +++ b/scripts/ogscope-network-init.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# OGScope 树莓派网络环境初始化(NetworkManager + Avahi) +# Raspberry Pi network bootstrap for OGScope (NM + Avahi) +# +# 用法 / Usage: +# sudo ./ogscope-network-init.sh init [--yes] +# sudo ./ogscope-network-init.sh diag +# sudo ./ogscope-network-init.sh ensure-systemd +# sudo ./ogscope-network-init.sh reset [--yes] +# +# 环境 / Environment: +# OGSCOPE_SKIP_NETWORK_INIT — install.sh 可跳过 / skip when set by installer + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +STA_NAME="OGScope-STA" +AP_NAME="OGScope-AP" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" +AP_IPV4="192.168.4.1/24" +AP_PSK="ogscopeadmin" + +STATE_DIR="/var/lib/ogscope" +ENV_DIR="/etc/ogscope" +ENV_FILE="${ENV_DIR}/network.env" +ID_FILE="${STATE_DIR}/network-id.txt" +SWITCH_SRC="${SCRIPT_DIR}/ogscope-wifi-switch.sh" +SWITCH_DST="/usr/local/bin/ogscope-wifi-switch" +SUDOERS_D="/etc/sudoers.d/ogscope-wifi" +SUDOERS_NMCLI="/etc/sudoers.d/ogscope-nmcli" +# systemd drop-in:老部署主 unit 可能无 EnvironmentFile / Drop-in for units missing EnvironmentFile +SYSTEMD_DROPIN_DIR="/etc/systemd/system/ogscope.service.d" +SYSTEMD_NETWORK_ENV_CONF="${SYSTEMD_DROPIN_DIR}/ogscope-network-env.conf" + +die() { echo "❌ $*" >&2; exit 1; } +info() { echo "ℹ️ $*"; } +ok() { echo "✅ $*"; } + +require_root() { + if [[ "${EUID}" -ne 0 ]]; then + die "请使用 root 或 sudo 运行 / Run as root or sudo" + fi +} + +# wlan0 MAC 取后 4 位十六进制作为设备后缀 / Last 4 hex chars from wlan0 MAC +compute_suffix() { + local mac hex + if [[ ! -r "/sys/class/net/${IFACE}/address" ]]; then + die "未找到 ${IFACE},请连接网卡或设置 OGSCOPE_WIFI_INTERFACE / Interface not found" + fi + mac="$(tr -d ' \n' <"/sys/class/net/${IFACE}/address")" + hex="${mac//:/}" + if [[ ${#hex} -lt 4 ]]; then + die "MAC 地址无效 / Invalid MAC: ${mac}" + fi + echo "${hex: -4}" | tr '[:upper:]' '[:lower:]' +} + +ensure_dirs() { + mkdir -p "${STATE_DIR}" "${ENV_DIR}" + chmod 755 "${STATE_DIR}" "${ENV_DIR}" 2>/dev/null || true +} + +install_switch_script() { + if [[ ! -f "${SWITCH_SRC}" ]]; then + die "未找到 ${SWITCH_SRC} / Switch script missing" + fi + install -m 755 "${SWITCH_SRC}" "${SWITCH_DST}" + ok "已安装 ${SWITCH_DST}" +} + +write_sudoers() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 sudoers(可手动添加 NOPASSWD ${SWITCH_DST})" + return 0 + fi + umask 077 + echo "${run_user} ALL=(ALL) NOPASSWD: ${SWITCH_DST}" >"${SUDOERS_D}.tmp" + chmod 440 "${SUDOERS_D}.tmp" + mv "${SUDOERS_D}.tmp" "${SUDOERS_D}" + ok "已写入 ${SUDOERS_D}(用户 ${run_user})" +} + +# Web API 直接调用 nmcli 时需免密,否则 polkit 报 Not authorized / NOPASSWD nmcli for API +write_sudoers_nmcli() { + local run_user="${OGSCOPE_SERVICE_USER:-${SUDO_USER:-}}" + local nmcli_bin + nmcli_bin="$(command -v nmcli 2>/dev/null || true)" + if [[ -z "${run_user}" ]]; then + info "未设置 OGSCOPE_SERVICE_USER/SUDO_USER,跳过 nmcli sudoers / Skipping nmcli sudoers" + return 0 + fi + if [[ -z "${nmcli_bin}" ]]; then + info "未找到 nmcli,跳过 ogscope-nmcli sudoers / nmcli not found" + return 0 + fi + umask 077 + echo "${run_user} ALL=(ALL) NOPASSWD: ${nmcli_bin}" >"${SUDOERS_NMCLI}.tmp" + chmod 440 "${SUDOERS_NMCLI}.tmp" + mv "${SUDOERS_NMCLI}.tmp" "${SUDOERS_NMCLI}" + ok "已写入 ${SUDOERS_NMCLI}(免密 ${nmcli_bin},Web「激活」已保存 WiFi 等)" +} + +write_network_env() { + local suffix="$1" + umask 077 + cat >"${ENV_FILE}.tmp" </dev/null; then + grep -vE '^127\.0\.1\.1[[:space:]]' "${hosts}" >"${hosts}.ogscope.tmp" \ + && mv "${hosts}.ogscope.tmp" "${hosts}" + fi + printf '127.0.1.1\t%s\n' "${host}" >>"${hosts}" + ok "已同步 ${hosts}:127.0.1.1 -> ${host}" +} + +# 写入 systemd drop-in,使 ogscope 进程加载 network.env(与 install.sh 主 unit 等价) +# Install systemd drop-in so ogscope loads network.env (same as install.sh main unit) +ensure_ogscope_systemd_network_env() { + if ! command -v systemctl >/dev/null 2>&1; then + info "无 systemctl,跳过 drop-in / No systemctl; skipping drop-in" + return 0 + fi + mkdir -p "${SYSTEMD_DROPIN_DIR}" + umask 022 + cat >"${SYSTEMD_NETWORK_ENV_CONF}.tmp" <<'EOF' +[Service] +EnvironmentFile=-/etc/ogscope/network.env +EOF + mv "${SYSTEMD_NETWORK_ENV_CONF}.tmp" "${SYSTEMD_NETWORK_ENV_CONF}" + chmod 644 "${SYSTEMD_NETWORK_ENV_CONF}" + systemctl daemon-reload + ok "已写入 ${SYSTEMD_NETWORK_ENV_CONF} 并 systemctl daemon-reload" +} + +create_nm_connections() { + local suffix="$1" + local ssid="OGScope_${suffix}" + + if ! command -v nmcli >/dev/null 2>&1; then + die "未安装 nmcli,请 apt install network-manager / nmcli not found" + fi + + # 删除同名旧连接(若存在)/ Remove stale connections + nmcli connection delete "${AP_NAME}" 2>/dev/null || true + nmcli connection delete "${STA_NAME}" 2>/dev/null || true + + # AP:热点 + 共享 IPv4(dnsmasq DHCP,避免客户端仅 169.254)/ Shared IPv4 + DHCP for clients + # 仅 WPA2(RSN+CCMP),避免部分系统提示弱加密 / WPA2-only to avoid weak-security warnings + nmcli connection add \ + type wifi \ + ifname "${IFACE}" \ + con-name "${AP_NAME}" \ + autoconnect no \ + wifi.mode ap \ + wifi.ssid "${ssid}" \ + wifi-sec.key-mgmt wpa-psk \ + wifi-sec.psk "${AP_PSK}" \ + wifi-sec.proto rsn \ + wifi-sec.pairwise ccmp \ + wifi-sec.group ccmp \ + ipv4.method shared \ + ipv4.addresses "${AP_IPV4}" \ + ipv6.method ignore + + # STA:占位 SSID(开放),供后续 API 改为 WPA / Placeholder STA; API sets WPA-PSK + nmcli connection add \ + type wifi \ + ifname "${IFACE}" \ + con-name "${STA_NAME}" \ + autoconnect no \ + wifi.mode infrastructure \ + wifi.ssid "__ogscope_sta_pending__" \ + wifi-sec.key-mgmt none \ + ipv4.method auto \ + ipv6.method ignore + + ok "已创建 NM 连接 ${AP_NAME}(SSID ${ssid})与 ${STA_NAME}" +} + +set_hostname_avahi() { + local suffix="$1" + local host="ogscope-${suffix}" + if command -v hostnamectl >/dev/null 2>&1; then + hostnamectl set-hostname "${host}" || info "hostnamectl 失败,可忽略 / hostnamectl failed" + fi + sync_hosts_for_hostname "${host}" + if command -v avahi-daemon >/dev/null 2>&1; then + systemctl enable avahi-daemon 2>/dev/null || true + systemctl restart avahi-daemon 2>/dev/null || true + ok "Avahi 已启用;尝试访问 http://${host}.local / Avahi enabled" + else + info "未安装 avahi-daemon,mDNS 不可用 / Install avahi-daemon for .local" + fi +} + +cmd_init() { + local yes_flag="${1:-}" + require_root + ensure_dirs + + local suffix + if [[ -f "${ID_FILE}" ]]; then + suffix="$(tr -d ' \n' <"${ID_FILE}")" + info "复用已保存后缀 / Reusing suffix: ${suffix}" + else + suffix="$(compute_suffix)" + echo "${suffix}" >"${ID_FILE}" + chmod 644 "${ID_FILE}" + ok "新建设备后缀 / Device suffix: ${suffix}" + fi + + if [[ "${yes_flag}" != "--yes" ]]; then + echo "将创建 SSID: OGScope_${suffix},密码: ${AP_PSK}" + read -r -p "继续? [y/N] " confirm + if [[ ! "${confirm}" =~ ^[yY]$ ]]; then + die "已取消 / Aborted" + fi + fi + + # 重建 wlan0 的 NM 连接会中断当前 WiFi(含经 WiFi 的 SSH)/ Recreating NM WiFi drops link (SSH over Wi-Fi included) + info "⚠️ 若当前经 WiFi 连接本机(含 SSH),下面步骤会重建无线配置,SSH 可能立即断开;请优先用有线网口、串口或本地控制台执行。" + info " If connected via Wi-Fi (including SSH), the next steps may drop this session; prefer Ethernet, serial, or local console." + + install_switch_script + create_nm_connections "${suffix}" + write_network_env "${suffix}" + ensure_ogscope_systemd_network_env + write_sudoers + write_sudoers_nmcli + set_hostname_avahi "${suffix}" + + ok "init 完成。请 systemctl restart ogscope 并连接热点 OGScope_${suffix} / init done" +} + +cmd_ensure_systemd() { + require_root + if [[ ! -f "${ENV_FILE}" ]]; then + die "缺少 ${ENV_FILE},请先 init / Missing network.env; run init first" + fi + # 与 init 一致:补 127.0.1.1,减轻 sudo unable to resolve host(同次 sudo 首行仍可能先警告一次) + # Match init: fix 127.0.1.1 for sudo; first sudo line may still warn before this runs + local hn="" + if [[ -f "${ID_FILE}" ]]; then + hn="ogscope-$(tr -d ' \n' <"${ID_FILE}")" + fi + if [[ -z "${hn}" ]]; then + hn="$(hostname 2>/dev/null | tr -d ' \n' || true)" + fi + if [[ -n "${hn}" ]]; then + sync_hosts_for_hostname "${hn}" + fi + ensure_ogscope_systemd_network_env + write_sudoers_nmcli + info "请执行: sudo systemctl restart ogscope / Please run: sudo systemctl restart ogscope" +} + +cmd_diag() { + info "=== OGScope 网络诊断 / Network diagnostics ===" + command -v nmcli >/dev/null && ok "nmcli: OK" || echo "❌ nmcli 缺失" + [[ -r "/sys/class/net/${IFACE}/address" ]] && ok "${IFACE} 存在" || echo "❌ ${IFACE} 不存在" + [[ -f "${ENV_FILE}" ]] && ok "network.env 存在: ${ENV_FILE}" || echo "⚠️ 无 ${ENV_FILE}" + [[ -f "${SWITCH_DST}" ]] && ok "切换脚本: ${SWITCH_DST}" || echo "⚠️ 无 ${SWITCH_DST}" + [[ -f "${SUDOERS_D}" ]] && ok "sudoers: ${SUDOERS_D}" || echo "⚠️ 无 sudoers" + [[ -f "${SUDOERS_NMCLI}" ]] && ok "sudoers nmcli: ${SUDOERS_NMCLI}" || echo "⚠️ 无 ${SUDOERS_NMCLI}(Web 激活 WiFi 可能报 Not authorized)" + command -v avahi-daemon >/dev/null && ok "avahi-daemon 已安装" || echo "⚠️ avahi-daemon 未安装" + if command -v nmcli >/dev/null; then + nmcli connection show "${AP_NAME}" >/dev/null 2>&1 && ok "连接 ${AP_NAME} 存在" || echo "⚠️ 无 ${AP_NAME}" + nmcli connection show "${STA_NAME}" >/dev/null 2>&1 && ok "连接 ${STA_NAME} 存在" || echo "⚠️ 无 ${STA_NAME}" + if nmcli connection show "${AP_NAME}" >/dev/null 2>&1; then + local ap_method + ap_method="$(nmcli -g ipv4.method connection show "${AP_NAME}" 2>/dev/null | head -n1 || true)" + if [[ "${ap_method}" == "manual" ]]; then + echo "⚠️ ${AP_NAME} 为 ipv4.method manual:客户端可能仅有 169.254,无法访问 192.168.4.1" + echo " 请执行: sudo ${SCRIPT_DIR}/$(basename "$0") init --yes(重建 AP 为 shared + DHCP)" + echo " Or: sudo ${SCRIPT_DIR}/$(basename "$0") init --yes to recreate AP (shared + DHCP)" + fi + fi + fi + if [[ -f "${ID_FILE}" ]]; then + info "后缀 / Suffix: $(cat "${ID_FILE}")" + fi + if command -v systemctl >/dev/null 2>&1 && systemctl cat ogscope >/dev/null 2>&1; then + if systemctl cat ogscope 2>/dev/null | grep -qE 'EnvironmentFile=.*etc/ogscope/network\.env'; then + ok "ogscope.service 已加载 network.env(主 unit 或 drop-in)" + else + echo "⚠️ ogscope 未引用 /etc/ogscope/network.env;API 可能显示 wifi_not_configured" + echo " 可执行: sudo ${0} ensure-systemd && sudo systemctl restart ogscope" + fi + else + info "无 ogscope systemd 单元或未安装 systemctl / No ogscope unit or no systemctl" + fi + local hn + hn="$(hostname 2>/dev/null || true)" + if [[ -n "${hn}" ]] && grep -qE "^127\.0\.1\.1[[:space:]].*${hn}" /etc/hosts 2>/dev/null; then + ok "/etc/hosts 含 127.0.1.1 -> ${hn}" + elif [[ -n "${hn}" ]]; then + echo "⚠️ /etc/hosts 可能缺少 127.0.1.1 ${hn},sudo 或提示 unable to resolve host" + echo " 可重新 init 或手动编辑 /etc/hosts" + fi +} + +cmd_reset() { + local yes_flag="${1:-}" + require_root + if [[ "${yes_flag}" != "--yes" ]]; then + read -r -p "将删除 ${AP_NAME}、${STA_NAME} 与 ${ENV_FILE},确认? [y/N] " confirm + if [[ ! "${confirm}" =~ ^[yY]$ ]]; then + die "已取消" + fi + fi + nmcli connection delete "${AP_NAME}" 2>/dev/null || true + nmcli connection delete "${STA_NAME}" 2>/dev/null || true + rm -f "${ENV_FILE}" + ok "reset 完成 / reset done" +} + +main() { + local sub="${1:-}" + shift || true + case "${sub}" in + init) cmd_init "${1:-}" ;; + diag) cmd_diag ;; + ensure-systemd) cmd_ensure_systemd ;; + reset) cmd_reset "${1:-}" ;; + *) + echo "Usage: sudo $0 init [--yes] | diag | ensure-systemd | reset [--yes]" >&2 + exit 1 + ;; + esac +} + +main "$@" diff --git a/scripts/ogscope-wifi-switch.sh b/scripts/ogscope-wifi-switch.sh new file mode 100755 index 0000000..1a0fe8c --- /dev/null +++ b/scripts/ogscope-wifi-switch.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# OGScope WiFi 模式切换(NetworkManager / nmcli) +# OGScope WiFi mode switch (NetworkManager) — STA (client) vs AP (hotspot) +# +# 用法 / Usage: +# ogscope-wifi-switch.sh status|ap|sta +# +# 环境变量(与 systemd 或 .env 中 OGSCOPE_* 对齐)/ Environment (match OGSCOPE_* in systemd/.env): +# OGSCOPE_WIFI_STA_CONNECTION — STA 连接名(连路由器)/ connection name for DHCP client mode +# OGSCOPE_WIFI_AP_CONNECTION — AP 热点连接名 / connection name for access point mode +# OGSCOPE_WIFI_INTERFACE — 无线接口,默认 wlan0 / wireless iface, default wlan0 +# +# 部署 / Deployment: +# sudo install -m 755 scripts/ogscope-wifi-switch.sh /usr/local/bin/ogscope-wifi-switch +# sudoers(将 %USER% 与路径替换为实际值)/ sudoers example: +# %USER% ALL=(ALL) NOPASSWD: /usr/local/bin/ogscope-wifi-switch +# +# 首次需在板上用 nmcli 创建两个 Profile(STA 与 AP),名称与上述变量一致。 +# Create both profiles once on the board with nmcli; names must match the variables above. + +set -euo pipefail + +# sudo 默认不传子进程环境(且 many sudoers 禁止 sudo -E),从 network.env 补全(与 systemd 同源) +# sudo often strips env; sudoers may reject -E — load network.env (same as systemd EnvironmentFile) +_DEFAULT_ENV="/etc/ogscope/network.env" +if [[ -r "${_DEFAULT_ENV}" ]] && { + [[ -z "${OGSCOPE_WIFI_STA_CONNECTION:-}" ]] || [[ -z "${OGSCOPE_WIFI_AP_CONNECTION:-}" ]]; +}; then + set -a + # shellcheck disable=SC1090 + source "${_DEFAULT_ENV}" + set +a +fi + +SCRIPT_NAME="$(basename "$0")" +CMD="${1:-}" + +STA="${OGSCOPE_WIFI_STA_CONNECTION:-}" +AP="${OGSCOPE_WIFI_AP_CONNECTION:-}" +IFACE="${OGSCOPE_WIFI_INTERFACE:-wlan0}" + +if ! command -v nmcli >/dev/null 2>&1; then + echo "ERROR=nmcli_not_found" >&2 + exit 127 +fi + +if [[ -z "$STA" || -z "$AP" ]]; then + echo "ERROR=missing_env_OGSCOPE_WIFI_STA_CONNECTION_or_OGSCOPE_WIFI_AP_CONNECTION" >&2 + exit 2 +fi + +# 当前活动连接名(指定接口)/ Active connection name for the wireless interface +_active_connection() { + nmcli -t -f GENERAL.CONNECTION device show "$IFACE" 2>/dev/null | sed -n 's/^GENERAL.CONNECTION://p' | head -n1 || true +} + +# AP 配置的 IPv4 地址(用于 status 展示)/ IPv4 addresses from AP profile +_ap_ipv4() { + nmcli -g ipv4.addresses connection show "$AP" 2>/dev/null | head -n1 || true +} + +case "$CMD" in +status) + ACTIVE="$(_active_connection)" + MODE="unknown" + if [[ "$ACTIVE" == "$AP" ]]; then + MODE="ap" + elif [[ "$ACTIVE" == "$STA" ]]; then + MODE="sta" + elif [[ -z "$ACTIVE" || "$ACTIVE" == "--" ]]; then + MODE="unknown" + fi + echo "MODE=${MODE}" + echo "ACTIVE_CONNECTION=${ACTIVE:-}" + echo "WIRELESS_INTERFACE=${IFACE}" + echo "STA_CONNECTION=${STA}" + echo "AP_CONNECTION=${AP}" + if [[ "$MODE" == "ap" ]]; then + echo "AP_IPV4=$(_ap_ipv4)" + else + echo "AP_IPV4=" + fi + ;; +ap) + nmcli connection down "$STA" 2>/dev/null || true + nmcli connection up "$AP" ifname "$IFACE" + ;; +sta) + nmcli connection down "$AP" 2>/dev/null || true + nmcli connection up "$STA" ifname "$IFACE" + ;; +*) + echo "Usage: ${SCRIPT_NAME} status|ap|sta" >&2 + exit 1 + ;; +esac diff --git a/scripts/quick_camera_check.sh b/scripts/quick_camera_check.sh new file mode 100644 index 0000000..c5db72c --- /dev/null +++ b/scripts/quick_camera_check.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# 快速相机状态检查脚本 / Quick camera status check script + +echo "🔍 OGScope 相机快速诊断" +echo "==========================" + +# 检查服务状态 / Check service status +echo "📋 检查服务状态..." +sudo systemctl status ogscope --no-pager -l + +echo "" +echo "📋 检查最近的服务日志..." +sudo journalctl -u ogscope --no-pager -l -n 20 + +echo "" +echo "📋 检查相机设备..." +ls -la /dev/video* 2>/dev/null || echo "未找到 /dev/video* 设备" + +echo "" +echo "📋 检查 libcamera..." +if command -v libcamera-hello >/dev/null 2>&1; then + echo "libcamera-hello 可用,检测相机:" + timeout 10 libcamera-hello --list-cameras 2>/dev/null || echo "libcamera-hello 执行失败" +else + echo "libcamera-hello 不可用" +fi + +echo "" +echo "📋 检查 Python 依赖..." +python3 -c " +try: + import picamera2 + print('✅ Picamera2 已安装') +except ImportError: + print('❌ Picamera2 未安装') + +try: + import cv2 + print('✅ OpenCV 已安装') +except ImportError: + print('⚠️ OpenCV 未安装 (直方图功能需要)') + +try: + import numpy + print('✅ NumPy 已安装') +except ImportError: + print('❌ NumPy 未安装') +" + +echo "" +echo "📋 测试 API 端点..." +if command -v curl >/dev/null 2>&1; then + echo "测试相机状态 API..." + curl -s http://localhost:8000/api/debug/camera/status | python3 -m json.tool 2>/dev/null || echo "API 请求失败" +else + echo "curl 不可用,无法测试 API" +fi + +echo "" +echo "🎯 建议的修复步骤:" +echo "1. 如果服务未运行: sudo systemctl start ogscope" +echo "2. 如果 Picamera2 未安装: sudo apt install python3-picamera2" +echo "3. 如果 OpenCV 未安装: sudo apt install python3-opencv" +echo "4. 如果相机设备不存在,检查硬件连接" +echo "5. 重启服务: sudo systemctl restart ogscope" diff --git a/scripts/start_debug_console.sh b/scripts/start_debug_console.sh deleted file mode 100755 index 68ff79c..0000000 --- a/scripts/start_debug_console.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# OGScope 调试控制台启动脚本 - -set -e - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# 打印带颜色的消息 -print_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -print_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -print_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -print_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# 检查是否在项目根目录 -if [ ! -f "pyproject.toml" ]; then - print_error "请在项目根目录运行此脚本" - exit 1 -fi - -print_info "启动 OGScope 调试控制台..." - -# 检查Poetry是否安装 -if ! command -v poetry &> /dev/null; then - print_error "Poetry 未安装,请先安装 Poetry" - print_info "安装命令: curl -sSL https://install.python-poetry.org | python3 -" - exit 1 -fi - -# 检查依赖是否安装 -print_info "检查依赖..." -if ! poetry check &> /dev/null; then - print_warning "依赖未完全安装,正在安装..." - poetry install -fi - -# 创建必要的目录 -print_info "创建必要的目录..." -mkdir -p ~/dev_captures -mkdir -p logs - -# 检查相机权限 -print_info "检查相机权限..." -if [ ! -w /dev/video0 ] && [ ! -w /dev/video1 ]; then - print_warning "相机设备权限可能不足,请确保用户有相机访问权限" -fi - -# 启动服务 -print_info "启动 Web 服务..." -print_success "调试控制台将在以下地址启动:" -print_success " 主界面: http://localhost:8000/" -print_success " 调试控制台: http://localhost:8000/debug" -print_success " API文档: http://localhost:8000/docs" -echo "" - -# 树莓派相机依赖环境变量(使用系统 picamera2 与 libcamera) -export PYTHONPATH="/usr/lib/python3/dist-packages:/usr/local/lib/python3.13/dist-packages:${PYTHONPATH}" -export LD_LIBRARY_PATH="/usr/lib/aarch64-linux-gnu:${LD_LIBRARY_PATH}" - -# 使用Poetry启动服务(主入口) -poetry run python -m ogscope.main diff --git a/scripts/sync_dev_board.sh b/scripts/sync_dev_board.sh new file mode 100755 index 0000000..f2f979a --- /dev/null +++ b/scripts/sync_dev_board.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# 本地 build 后同步到开发板 / Build locally then rsync to dev board +# 用法 / Usage: +# export OGSCOPE_DEV_USER=ogstartech +# export OGSCOPE_DEV_HOST=192.168.31.16 +# export OGSCOPE_DEV_PATH=/path/to/OGScope +# ./scripts/sync_dev_board.sh +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT/web/analysis-ui" +npm run build +if [[ -z "${OGSCOPE_DEV_HOST:-}" || -z "${OGSCOPE_DEV_PATH:-}" ]]; then + echo "请设置 OGSCOPE_DEV_HOST 与 OGSCOPE_DEV_PATH(可选 OGSCOPE_DEV_USER)" >&2 + echo "Set OGSCOPE_DEV_HOST and OGSCOPE_DEV_PATH (optional OGSCOPE_DEV_USER)" >&2 + exit 1 +fi +RSYNC_TARGET="${OGSCOPE_DEV_USER:+$OGSCOPE_DEV_USER@}$OGSCOPE_DEV_HOST:$OGSCOPE_DEV_PATH" +rsync -avz --delete \ + "$ROOT/web/static/analysis-lab/" \ + "$RSYNC_TARGET/web/static/analysis-lab/" +echo "已同步 web/static/analysis-lab/ -> $RSYNC_TARGET/web/static/analysis-lab/" +echo "可选 / Optional: ssh $RSYNC_TARGET 'sudo systemctl restart ogscope'" diff --git a/scripts/test_api_refactor.py b/scripts/test_api_refactor.py deleted file mode 100644 index 0e10f5f..0000000 --- a/scripts/test_api_refactor.py +++ /dev/null @@ -1,281 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -API重构测试脚本 -验证模块化API结构是否正常工作 -""" - -import sys -import os -sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -def test_api_imports(): - """测试API模块导入""" - print("🔍 测试API模块导入...") - - try: - # 测试主API模块 - from ogscope.web.api.main import router as main_router - print("✅ 主API模块导入成功") - - # 测试各个子模块 - from ogscope.web.api.camera.routes import router as camera_router - print("✅ 相机API模块导入成功") - - from ogscope.web.api.alignment.routes import router as alignment_router - print("✅ 极轴校准API模块导入成功") - - from ogscope.web.api.system.routes import router as system_router - print("✅ 系统API模块导入成功") - - from ogscope.web.api.debug.routes import router as debug_router - print("✅ 调试控制台API模块导入成功") - - # 测试数据模型 - from ogscope.web.api.models.schemas import ( - CameraSettings, - PolarAlignStatus, - CameraPreset, - CaptureInfo, - SystemInfo, - AlignmentStatus - ) - print("✅ 数据模型导入成功") - - # 测试调试服务 - from ogscope.web.api.debug.services import ( - DebugCameraService, - DebugPresetService, - DebugFileService - ) - print("✅ 调试服务模块导入成功") - - return True - - except ImportError as e: - print(f"❌ 模块导入失败: {e}") - return False - except Exception as e: - print(f"❌ 测试异常: {e}") - return False - - -def test_router_registration(): - """测试路由注册""" - print("\n🔍 测试路由注册...") - - try: - from ogscope.web.api.main import router as main_router - - # 检查路由数量 - routes = [route for route in main_router.routes] - print(f"✅ 主路由器包含 {len(routes)} 个路由") - - # 检查各个模块的路由 - route_paths = [route.path for route in routes if hasattr(route, 'path')] - - # 检查相机路由 - camera_routes = [path for path in route_paths if path.startswith('/camera')] - print(f"✅ 相机路由: {len(camera_routes)} 个") - - # 检查极轴校准路由 - alignment_routes = [path for path in route_paths if 'alignment' in path or 'polar-align' in path] - print(f"✅ 极轴校准路由: {len(alignment_routes)} 个") - - # 检查系统路由 - system_routes = [path for path in route_paths if path.startswith('/system')] - print(f"✅ 系统路由: {len(system_routes)} 个") - - # 检查调试路由 - debug_routes = [path for path in route_paths if path.startswith('/debug')] - print(f"✅ 调试控制台路由: {len(debug_routes)} 个") - - return True - - except Exception as e: - print(f"❌ 路由注册测试失败: {e}") - return False - - -def test_data_models(): - """测试数据模型""" - print("\n🔍 测试数据模型...") - - try: - from ogscope.web.api.models.schemas import ( - CameraSettings, - PolarAlignStatus, - CameraPreset, - CaptureInfo, - SystemInfo, - AlignmentStatus - ) - - # 测试CameraSettings - camera_settings = CameraSettings(exposure=10000, gain=2.0) - assert camera_settings.exposure == 10000 - assert camera_settings.gain == 2.0 - print("✅ CameraSettings 模型测试通过") - - # 测试PolarAlignStatus - polar_status = PolarAlignStatus( - is_running=True, - progress=50.0, - azimuth_error=1.5, - altitude_error=2.0 - ) - assert polar_status.is_running == True - assert polar_status.progress == 50.0 - print("✅ PolarAlignStatus 模型测试通过") - - # 测试CameraPreset - preset = CameraPreset( - name="测试预设", - description="测试描述", - exposure_us=15000, - analogue_gain=3.0, - digital_gain=1.5 - ) - assert preset.name == "测试预设" - assert preset.exposure_us == 15000 - print("✅ CameraPreset 模型测试通过") - - return True - - except Exception as e: - print(f"❌ 数据模型测试失败: {e}") - return False - - -def test_app_integration(): - """测试应用集成""" - print("\n🔍 测试应用集成...") - - try: - from ogscope.web.app import app - - # 检查应用是否正确创建 - assert app is not None - print("✅ FastAPI应用创建成功") - - # 检查路由是否正确注册 - routes = [route for route in app.routes] - api_routes = [route for route in routes if hasattr(route, 'path') and route.path.startswith('/api')] - print(f"✅ API路由注册成功: {len(api_routes)} 个") - - return True - - except Exception as e: - print(f"❌ 应用集成测试失败: {e}") - return False - - -def test_directory_structure(): - """测试目录结构""" - print("\n🔍 测试目录结构...") - - import os - from pathlib import Path - - api_dir = Path("ogscope/web/api") - - # 检查主要目录 - required_dirs = [ - "camera", - "debug", - "alignment", - "system", - "models" - ] - - for dir_name in required_dirs: - dir_path = api_dir / dir_name - if dir_path.exists(): - print(f"✅ 目录存在: {dir_name}") - else: - print(f"❌ 目录缺失: {dir_name}") - return False - - # 检查主要文件 - required_files = [ - "main.py", - "camera/routes.py", - "debug/routes.py", - "debug/services.py", - "alignment/routes.py", - "system/routes.py", - "models/schemas.py" - ] - - for file_name in required_files: - file_path = api_dir / file_name - if file_path.exists(): - print(f"✅ 文件存在: {file_name}") - else: - print(f"❌ 文件缺失: {file_name}") - return False - - return True - - -def main(): - """主测试函数""" - print("🚀 开始API重构测试...") - print("=" * 50) - - tests = [ - ("目录结构", test_directory_structure), - ("模块导入", test_api_imports), - ("路由注册", test_router_registration), - ("数据模型", test_data_models), - ("应用集成", test_app_integration), - ] - - passed = 0 - total = len(tests) - - for test_name, test_func in tests: - print(f"\n📋 {test_name}") - print("-" * 30) - try: - if test_func(): - passed += 1 - print(f"✅ {test_name} 测试通过") - else: - print(f"❌ {test_name} 测试失败") - except Exception as e: - print(f"❌ {test_name} 测试异常: {e}") - - print("\n" + "=" * 50) - print(f"📊 测试结果: {passed}/{total} 通过") - - if passed == total: - print("🎉 所有测试通过!API重构成功!") - print("\n📁 新的API结构:") - print("ogscope/web/api/") - print("├── main.py # 主路由文件") - print("├── camera/") - print("│ └── routes.py # 相机API路由") - print("├── debug/") - print("│ ├── routes.py # 调试控制台API路由") - print("│ └── services.py # 调试控制台服务层") - print("├── alignment/") - print("│ └── routes.py # 极轴校准API路由") - print("├── system/") - print("│ └── routes.py # 系统API路由") - print("└── models/") - print(" └── schemas.py # 数据模型定义") - - print("\n✨ 重构优势:") - print("- 模块化设计,职责清晰") - print("- 易于维护和扩展") - print("- 代码复用性更好") - print("- 符合最佳实践") - else: - print("⚠️ 部分测试失败,请检查错误信息并修复问题。") - - return passed == total - - -if __name__ == "__main__": - main() diff --git a/scripts/test_debug_console.py b/scripts/test_debug_console.py deleted file mode 100644 index 51ce513..0000000 --- a/scripts/test_debug_console.py +++ /dev/null @@ -1,292 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -OGScope 调试控制台测试脚本 -用于验证调试控制台的基本功能 -""" - -import asyncio -import json -import requests -from pathlib import Path -import time - - -class DebugConsoleTester: - """调试控制台测试器""" - - def __init__(self, base_url="http://localhost:8000"): - self.base_url = base_url - self.api_base = f"{base_url}/api/debug" - - def test_camera_status(self): - """测试相机状态API""" - print("🔍 测试相机状态API...") - try: - response = requests.get(f"{self.api_base}/camera/status") - if response.status_code == 200: - status = response.json() - print(f"✅ 相机状态: {status}") - return True - else: - print(f"❌ 相机状态API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 相机状态API异常: {e}") - return False - - def test_camera_start(self): - """测试相机启动API""" - print("🔍 测试相机启动API...") - try: - response = requests.post(f"{self.api_base}/camera/start") - if response.status_code == 200: - result = response.json() - print(f"✅ 相机启动: {result}") - return True - else: - print(f"❌ 相机启动API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 相机启动API异常: {e}") - return False - - def test_camera_stop(self): - """测试相机停止API""" - print("🔍 测试相机停止API...") - try: - response = requests.post(f"{self.api_base}/camera/stop") - if response.status_code == 200: - result = response.json() - print(f"✅ 相机停止: {result}") - return True - else: - print(f"❌ 相机停止API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 相机停止API异常: {e}") - return False - - def test_camera_preview(self): - """测试相机预览API""" - print("🔍 测试相机预览API...") - try: - response = requests.get(f"{self.api_base}/camera/preview") - if response.status_code == 200: - print(f"✅ 相机预览: 获取到 {len(response.content)} 字节的图像数据") - return True - else: - print(f"❌ 相机预览API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 相机预览API异常: {e}") - return False - - def test_camera_settings(self): - """测试相机设置API""" - print("🔍 测试相机设置API...") - try: - settings = { - "exposure": 15000, - "gain": 2.0 - } - response = requests.post( - f"{self.api_base}/camera/settings", - json=settings - ) - if response.status_code == 200: - result = response.json() - print(f"✅ 相机设置: {result}") - return True - else: - print(f"❌ 相机设置API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 相机设置API异常: {e}") - return False - - def test_presets(self): - """测试预设管理API""" - print("🔍 测试预设管理API...") - try: - # 获取预设列表 - response = requests.get(f"{self.api_base}/camera/presets") - if response.status_code == 200: - presets = response.json() - print(f"✅ 获取预设列表: {len(presets.get('presets', []))} 个预设") - - # 保存测试预设 - test_preset = { - "name": "测试预设", - "description": "这是一个测试预设", - "exposure_us": 20000, - "analogue_gain": 3.0, - "digital_gain": 1.5 - } - - response = requests.post( - f"{self.api_base}/camera/presets", - json=test_preset - ) - if response.status_code == 200: - result = response.json() - print(f"✅ 保存预设: {result}") - return True - else: - print(f"❌ 保存预设API失败: {response.status_code}") - return False - - except Exception as e: - print(f"❌ 预设管理API异常: {e}") - return False - - def test_files(self): - """测试文件管理API""" - print("🔍 测试文件管理API...") - try: - response = requests.get(f"{self.api_base}/files") - if response.status_code == 200: - files = response.json() - print(f"✅ 获取文件列表: {len(files.get('files', []))} 个文件") - return True - else: - print(f"❌ 文件管理API失败: {response.status_code}") - return False - except Exception as e: - print(f"❌ 文件管理API异常: {e}") - return False - - def test_web_interface(self): - """测试Web界面""" - print("🔍 测试Web界面...") - try: - # 测试主页面 - response = requests.get(f"{self.base_url}/") - if response.status_code == 200: - print("✅ 主页面加载正常") - else: - print(f"❌ 主页面加载失败: {response.status_code}") - return False - - # 测试调试控制台页面 - response = requests.get(f"{self.base_url}/debug") - if response.status_code == 200: - print("✅ 调试控制台页面加载正常") - return True - else: - print(f"❌ 调试控制台页面加载失败: {response.status_code}") - return False - - except Exception as e: - print(f"❌ Web界面测试异常: {e}") - return False - - def check_dependencies(self): - """检查依赖项""" - print("🔍 检查依赖项...") - - # 检查OpenCV - try: - import cv2 - print("✅ OpenCV 已安装") - except ImportError: - print("❌ OpenCV 未安装,请运行: pip install opencv-python") - return False - - # 检查Picamera2 - try: - import picamera2 - print("✅ Picamera2 已安装") - except ImportError: - print("❌ Picamera2 未安装,请运行: sudo apt install python3-picamera2") - return False - - # 检查存储目录 - captures_dir = Path.home() / "dev_captures" - if captures_dir.exists(): - print(f"✅ 存储目录存在: {captures_dir}") - else: - print(f"⚠️ 存储目录不存在,将自动创建: {captures_dir}") - captures_dir.mkdir(exist_ok=True) - - return True - - def run_all_tests(self): - """运行所有测试""" - print("🚀 开始调试控制台测试...") - print("=" * 50) - - tests = [ - ("依赖项检查", self.check_dependencies), - ("Web界面", self.test_web_interface), - ("相机状态", self.test_camera_status), - ("相机启动", self.test_camera_start), - ("相机预览", self.test_camera_preview), - ("相机设置", self.test_camera_settings), - ("相机停止", self.test_camera_stop), - ("预设管理", self.test_presets), - ("文件管理", self.test_files), - ] - - passed = 0 - total = len(tests) - - for test_name, test_func in tests: - print(f"\n📋 {test_name}") - print("-" * 30) - try: - if test_func(): - passed += 1 - print(f"✅ {test_name} 测试通过") - else: - print(f"❌ {test_name} 测试失败") - except Exception as e: - print(f"❌ {test_name} 测试异常: {e}") - - print("\n" + "=" * 50) - print(f"📊 测试结果: {passed}/{total} 通过") - - if passed == total: - print("🎉 所有测试通过!调试控制台可以正常使用。") - print("\n📖 使用说明:") - print("1. 访问 http://localhost:8000/debug 打开调试控制台") - print("2. 点击 '启动预览' 开始相机预览") - print("3. 使用 '拍摄控制' 标签页进行拍照和录制") - print("4. 使用 '参数设置' 标签页调整相机参数") - print("5. 使用 '预设管理' 标签页保存和加载预设") - print("6. 使用 '文件管理' 标签页查看和下载文件") - else: - print("⚠️ 部分测试失败,请检查错误信息并修复问题。") - - return passed == total - - -def main(): - """主函数""" - import argparse - - parser = argparse.ArgumentParser(description="OGScope 调试控制台测试") - parser.add_argument("--url", default="http://localhost:8000", - help="服务器URL (默认: http://localhost:8000)") - parser.add_argument("--test", choices=["all", "api", "web", "deps"], - default="all", help="测试类型") - - args = parser.parse_args() - - tester = DebugConsoleTester(args.url) - - if args.test == "all": - tester.run_all_tests() - elif args.test == "api": - tester.test_camera_status() - tester.test_camera_start() - tester.test_camera_preview() - tester.test_camera_stop() - elif args.test == "web": - tester.test_web_interface() - elif args.test == "deps": - tester.check_dependencies() - - -if __name__ == "__main__": - main() diff --git a/scripts/test_supersample.py b/scripts/test_supersample.py deleted file mode 100644 index a2f1756..0000000 --- a/scripts/test_supersample.py +++ /dev/null @@ -1,355 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -超采样功能验证测试脚本 - -此脚本用于验证 OGScope 的超采样设置是否有效, -确保后续开发中获取的视频流是经过超采样的高质量视频流。 - -使用方法: - python scripts/test_supersample.py - -测试内容: -1. 验证超采样设置的基本配置 -2. 测试不同分辨率下的超采样效果 -3. 验证实际捕获图像的尺寸 -4. 检查超采样比例和质量评估 -""" - -import sys -import os -import asyncio -import requests -import json -from pathlib import Path - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - -from ogscope.hardware.camera import create_camera -from ogscope.config import get_settings - - -class SupersampleTester: - """超采样测试器""" - - def __init__(self, base_url: str = "http://localhost:8000"): - self.base_url = base_url - self.test_results = [] - - def log(self, message: str, level: str = "INFO"): - """记录日志""" - print(f"[{level}] {message}") - - def test_camera_direct(self): - """直接测试相机类的超采样功能""" - self.log("=== 直接相机类测试 ===") - - try: - # 创建相机实例 - config = { - "type": "imx327_mipi", - "width": 640, - "height": 360, - "fps": 5, - "exposure_us": 10000, - "analogue_gain": 1.0, - "rotation": 180, - "sampling_mode": "supersample", - } - - camera = create_camera(config) - if not camera: - self.log("无法创建相机实例", "ERROR") - return False - - # 初始化相机 - if not camera.initialize(): - self.log("相机初始化失败", "ERROR") - return False - - # 获取相机信息 - info = camera.get_camera_info() - self.log(f"相机信息: {json.dumps(info, indent=2, ensure_ascii=False)}") - - # 验证超采样设置 - sampling_mode = info.get('sampling_mode', 'unknown') - capture_width = info.get('capture_width', 0) - capture_height = info.get('capture_height', 0) - output_width = info.get('output_width', 0) - output_height = info.get('output_height', 0) - - if sampling_mode == 'supersample': - self.log("✓ 超采样模式已启用", "SUCCESS") - - if capture_width > output_width and capture_height > output_height: - ratio = min(capture_width / output_width, capture_height / output_height) - self.log(f"✓ 超采样比例: {ratio:.2f}x", "SUCCESS") - - if ratio >= 1.5: - self.log("✓ 超采样质量: 优秀", "SUCCESS") - elif ratio >= 1.2: - self.log("✓ 超采样质量: 良好", "SUCCESS") - else: - self.log("⚠ 超采样质量: 较低", "WARNING") - else: - self.log("✗ 捕获分辨率不高于输出分辨率", "ERROR") - return False - else: - self.log(f"✗ 当前不是超采样模式: {sampling_mode}", "ERROR") - return False - - # 测试图像捕获 - if camera.start_capture(): - self.log("✓ 相机开始捕获", "SUCCESS") - - # 捕获一张图像 - image = camera.capture_image() - if image is not None: - actual_height, actual_width = image.shape[:2] - self.log(f"✓ 捕获图像尺寸: {actual_width}x{actual_height}", "SUCCESS") - - # 验证尺寸是否匹配 - if actual_width == output_width and actual_height == output_height: - self.log("✓ 图像尺寸与预期输出尺寸匹配", "SUCCESS") - else: - self.log(f"✗ 图像尺寸不匹配!预期: {output_width}x{output_height}, 实际: {actual_width}x{actual_height}", "ERROR") - return False - else: - self.log("✗ 无法捕获图像", "ERROR") - return False - - camera.stop_capture() - else: - self.log("✗ 无法启动相机捕获", "ERROR") - return False - - return True - - except Exception as e: - self.log(f"直接测试失败: {e}", "ERROR") - return False - - async def test_api_endpoints(self): - """测试 API 端点""" - self.log("=== API 端点测试 ===") - - try: - # 测试超采样验证端点 - response = requests.get(f"{self.base_url}/debug/camera/verify-supersample") - if response.status_code == 200: - data = response.json() - self.log("✓ 超采样验证 API 调用成功", "SUCCESS") - - verification = data.get('verification', {}) - self.log(f"验证结果: {json.dumps(verification, indent=2, ensure_ascii=False)}") - - if verification.get('is_supersample_active'): - self.log("✓ API 确认超采样已激活", "SUCCESS") - else: - self.log("✗ API 确认超采样未激活", "ERROR") - return False - else: - self.log(f"✗ 超采样验证 API 调用失败: {response.status_code}", "ERROR") - return False - - # 测试图像尺寸验证端点 - response = requests.post(f"{self.base_url}/debug/camera/test-image-size") - if response.status_code == 200: - data = response.json() - self.log("✓ 图像尺寸测试 API 调用成功", "SUCCESS") - - test_result = data.get('test_result', {}) - self.log(f"测试结果: {json.dumps(test_result, indent=2, ensure_ascii=False)}") - - if test_result.get('supersample_working'): - self.log("✓ API 确认超采样功能正常工作", "SUCCESS") - else: - self.log("✗ API 确认超采样功能未正常工作", "ERROR") - return False - else: - self.log(f"✗ 图像尺寸测试 API 调用失败: {response.status_code}", "ERROR") - return False - - return True - - except Exception as e: - self.log(f"API 测试失败: {e}", "ERROR") - return False - - async def test_different_resolutions(self): - """测试不同分辨率下的超采样效果""" - self.log("=== 多分辨率测试 ===") - - test_resolutions = [ - {"width": 320, "height": 240, "name": "QVGA"}, - {"width": 640, "height": 360, "name": "360p"}, - {"width": 640, "height": 480, "name": "VGA"}, - {"width": 1280, "height": 720, "name": "720p"}, - ] - - results = [] - - for res in test_resolutions: - self.log(f"测试分辨率: {res['name']} ({res['width']}x{res['height']})") - - try: - # 设置分辨率 - response = requests.post( - f"{self.base_url}/debug/camera/size", - params={"width": res["width"], "height": res["height"]} - ) - - if response.status_code == 200: - self.log(f"✓ {res['name']} 分辨率设置成功", "SUCCESS") - - # 验证超采样状态 - response = requests.get(f"{self.base_url}/debug/camera/verify-supersample") - if response.status_code == 200: - data = response.json() - verification = data.get('verification', {}) - - if verification.get('is_supersample_active'): - ratio = verification.get('supersample_ratio', 1.0) - status = verification.get('verification_status', 'unknown') - self.log(f"✓ {res['name']} 超采样比例: {ratio:.2f}x, 状态: {status}", "SUCCESS") - results.append({ - "resolution": res['name'], - "width": res["width"], - "height": res["height"], - "ratio": ratio, - "status": status, - "success": True - }) - else: - self.log(f"✗ {res['name']} 超采样未激活", "ERROR") - results.append({ - "resolution": res['name'], - "success": False - }) - else: - self.log(f"✗ {res['name']} 验证失败", "ERROR") - results.append({ - "resolution": res['name'], - "success": False - }) - else: - self.log(f"✗ {res['name']} 分辨率设置失败", "ERROR") - results.append({ - "resolution": res['name'], - "success": False - }) - - except Exception as e: - self.log(f"✗ {res['name']} 测试异常: {e}", "ERROR") - results.append({ - "resolution": res['name'], - "success": False, - "error": str(e) - }) - - # 汇总结果 - success_count = sum(1 for r in results if r.get('success', False)) - total_count = len(results) - - self.log(f"多分辨率测试完成: {success_count}/{total_count} 成功", - "SUCCESS" if success_count == total_count else "WARNING") - - return results - - def generate_report(self, results: dict): - """生成测试报告""" - self.log("=== 测试报告 ===") - - report = { - "timestamp": asyncio.get_event_loop().time(), - "direct_test": results.get('direct_test', False), - "api_test": results.get('api_test', False), - "resolution_tests": results.get('resolution_tests', []), - "overall_status": "PASS" if all([ - results.get('direct_test', False), - results.get('api_test', False), - len([r for r in results.get('resolution_tests', []) if r.get('success', False)]) > 0 - ]) else "FAIL" - } - - self.log(f"总体状态: {report['overall_status']}", - "SUCCESS" if report['overall_status'] == "PASS" else "ERROR") - - # 保存报告到文件 - report_file = project_root / "test_supersample_report.json" - with open(report_file, 'w', encoding='utf-8') as f: - json.dump(report, f, indent=2, ensure_ascii=False) - - self.log(f"测试报告已保存到: {report_file}") - - return report - - async def run_all_tests(self): - """运行所有测试""" - self.log("开始超采样功能验证测试...") - - results = {} - - # 1. 直接相机类测试 - results['direct_test'] = self.test_camera_direct() - - # 2. API 端点测试 - results['api_test'] = await self.test_api_endpoints() - - # 3. 多分辨率测试 - results['resolution_tests'] = await self.test_different_resolutions() - - # 4. 生成报告 - report = self.generate_report(results) - - return report - - -async def main(): - """主函数""" - print("OGScope 超采样功能验证测试") - print("=" * 50) - - # 检查是否在开发环境中运行 - if len(sys.argv) > 1 and sys.argv[1] == "--api-only": - # 仅测试 API 端点(适用于远程测试) - base_url = sys.argv[2] if len(sys.argv) > 2 else "http://192.168.31.18:8000" - tester = SupersampleTester(base_url) - - api_result = await tester.test_api_endpoints() - resolution_results = await tester.test_different_resolutions() - - results = { - 'api_test': api_result, - 'resolution_tests': resolution_results - } - - report = tester.generate_report(results) - else: - # 完整测试(包括直接相机类测试) - tester = SupersampleTester() - report = await tester.run_all_tests() - - print("\n" + "=" * 50) - if report['overall_status'] == 'PASS': - print("✅ 超采样功能验证通过!") - print("✅ 后续开发中的视频流将使用超采样的高质量图像") - else: - print("❌ 超采样功能验证失败!") - print("❌ 请检查相机配置和超采样设置") - - return report['overall_status'] == 'PASS' - - -if __name__ == "__main__": - try: - success = asyncio.run(main()) - sys.exit(0 if success else 1) - except KeyboardInterrupt: - print("\n测试被用户中断") - sys.exit(1) - except Exception as e: - print(f"\n测试过程中发生错误: {e}") - sys.exit(1) diff --git a/scripts/test_supersample_quality.py b/scripts/test_supersample_quality.py deleted file mode 100644 index 7bdea49..0000000 --- a/scripts/test_supersample_quality.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -超采样画质对比测试脚本 - -此脚本用于对比超采样模式和原生模式的画质差异, -通过捕获图像并分析图像质量指标来验证超采样效果。 - -使用方法: - python scripts/test_supersample_quality.py -""" - -import sys -import os -import requests -import json -import time -from pathlib import Path - -# 添加项目根目录到 Python 路径 -project_root = Path(__file__).parent.parent -sys.path.insert(0, str(project_root)) - - -class SupersampleQualityTester: - """超采样画质测试器""" - - def __init__(self, base_url: str = "http://192.168.31.18:8000"): - self.base_url = base_url - self.test_results = [] - - def log(self, message: str, level: str = "INFO"): - """记录日志""" - print(f"[{level}] {message}") - - def capture_test_image(self, mode: str, resolution: str): - """捕获测试图像""" - try: - # 设置采样模式 - mode_response = requests.post( - f"{self.base_url}/api/debug/camera/sampling", - params={"mode": mode} - ) - if mode_response.status_code != 200: - self.log(f"设置采样模式失败: {mode}", "ERROR") - return None - - # 设置分辨率 - width, height = map(int, resolution.split('x')) - size_response = requests.post( - f"{self.base_url}/api/debug/camera/size", - params={"width": width, "height": height} - ) - if size_response.status_code != 200: - self.log(f"设置分辨率失败: {resolution}", "ERROR") - return None - - # 等待设置生效 - time.sleep(2) - - # 捕获图像 - capture_response = requests.post(f"{self.base_url}/api/debug/camera/capture") - if capture_response.status_code != 200: - self.log(f"捕获图像失败", "ERROR") - return None - - return capture_response.json() - - except Exception as e: - self.log(f"捕获测试图像失败: {e}", "ERROR") - return None - - def get_camera_info(self): - """获取相机信息""" - try: - response = requests.get(f"{self.base_url}/api/debug/camera/verify-supersample") - if response.status_code == 200: - return response.json() - return None - except Exception as e: - self.log(f"获取相机信息失败: {e}", "ERROR") - return None - - def test_resolution_scenarios(self): - """测试不同分辨率场景""" - scenarios = [ - {"resolution": "320x240", "name": "QVGA"}, - {"resolution": "640x360", "name": "360p"}, - {"resolution": "1280x720", "name": "720p"}, - ] - - results = [] - - for scenario in scenarios: - self.log(f"测试分辨率: {scenario['name']} ({scenario['resolution']})") - - # 测试原生模式 - self.log(f" 测试原生模式...") - native_result = self.capture_test_image("native", scenario['resolution']) - native_info = self.get_camera_info() - - # 测试超采样模式 - self.log(f" 测试超采样模式...") - supersample_result = self.capture_test_image("supersample", scenario['resolution']) - supersample_info = self.get_camera_info() - - # 分析结果 - analysis = { - "resolution": scenario['resolution'], - "name": scenario['name'], - "native": { - "success": native_result is not None, - "info": native_info.get('camera_info', {}) if native_info else {}, - "verification": native_info.get('verification', {}) if native_info else {} - }, - "supersample": { - "success": supersample_result is not None, - "info": supersample_info.get('camera_info', {}) if supersample_info else {}, - "verification": supersample_info.get('verification', {}) if supersample_info else {} - } - } - - # 计算差异 - if analysis["native"]["success"] and analysis["supersample"]["success"]: - native_ratio = analysis["native"]["verification"].get("supersample_ratio", 1.0) - supersample_ratio = analysis["supersample"]["verification"].get("supersample_ratio", 1.0) - ratio_improvement = supersample_ratio / native_ratio if native_ratio > 0 else 0 - - analysis["quality_improvement"] = { - "native_ratio": native_ratio, - "supersample_ratio": supersample_ratio, - "improvement_factor": ratio_improvement, - "expected_quality_gain": "显著" if ratio_improvement >= 1.5 else "轻微" if ratio_improvement > 1.0 else "无" - } - - results.append(analysis) - self.log(f" 完成 {scenario['name']} 测试") - - return results - - def generate_quality_report(self, results): - """生成画质对比报告""" - self.log("=== 超采样画质对比报告 ===") - - for result in results: - self.log(f"\n分辨率: {result['name']} ({result['resolution']})") - - if result["native"]["success"] and result["supersample"]["success"]: - native_info = result["native"]["info"] - supersample_info = result["supersample"]["info"] - - self.log(f" 原生模式:") - self.log(f" 捕获分辨率: {native_info.get('capture_width', 0)}x{native_info.get('capture_height', 0)}") - self.log(f" 输出分辨率: {native_info.get('output_width', 0)}x{native_info.get('output_height', 0)}") - self.log(f" 超采样比例: {result['native']['verification'].get('supersample_ratio', 1.0)}x") - - self.log(f" 超采样模式:") - self.log(f" 捕获分辨率: {supersample_info.get('capture_width', 0)}x{supersample_info.get('capture_height', 0)}") - self.log(f" 输出分辨率: {supersample_info.get('output_width', 0)}x{supersample_info.get('output_height', 0)}") - self.log(f" 超采样比例: {result['supersample']['verification'].get('supersample_ratio', 1.0)}x") - - if "quality_improvement" in result: - improvement = result["quality_improvement"] - self.log(f" 画质提升:") - self.log(f" 超采样比例提升: {improvement['improvement_factor']:.1f}x") - self.log(f" 预期画质增益: {improvement['expected_quality_gain']}") - - if improvement['improvement_factor'] >= 1.5: - self.log(f" ✅ 在此分辨率下,超采样应该能提供显著的画质提升", "SUCCESS") - elif improvement['improvement_factor'] > 1.0: - self.log(f" ⚠️ 在此分辨率下,超采样提供轻微的画质提升", "WARNING") - else: - self.log(f" ❌ 在此分辨率下,超采样没有画质提升", "ERROR") - else: - self.log(f" ❌ 测试失败", "ERROR") - - # 总结建议 - self.log(f"\n=== 画质对比建议 ===") - self.log(f"1. 在高分辨率下(720p及以上),超采样效果更明显") - self.log(f"2. 在低光照条件下,超采样减少噪声的效果更显著") - self.log(f"3. 静态图像比视频流更容易看出画质差异") - self.log(f"4. 使用高质量的显示设备能更好地看出差异") - self.log(f"5. 建议在1280x720或更高分辨率下测试超采样效果") - - def run_quality_test(self): - """运行画质对比测试""" - self.log("开始超采样画质对比测试...") - - # 确保相机启动 - try: - start_response = requests.post(f"{self.base_url}/api/debug/camera/start") - if start_response.status_code == 200: - self.log("相机启动成功", "SUCCESS") - else: - self.log("相机启动失败", "ERROR") - return False - except Exception as e: - self.log(f"相机启动异常: {e}", "ERROR") - return False - - # 测试不同分辨率场景 - results = self.test_resolution_scenarios() - - # 生成报告 - self.generate_quality_report(results) - - return True - - -async def main(): - """主函数""" - print("OGScope 超采样画质对比测试") - print("=" * 50) - - tester = SupersampleQualityTester() - success = tester.run_quality_test() - - print("\n" + "=" * 50) - if success: - print("✅ 画质对比测试完成!") - print("💡 提示:在高分辨率下更容易看出超采样的画质提升效果") - else: - print("❌ 画质对比测试失败!") - - return success - - -if __name__ == "__main__": - try: - import asyncio - success = asyncio.run(main()) - sys.exit(0 if success else 1) - except KeyboardInterrupt: - print("\n测试被用户中断") - sys.exit(1) - except Exception as e: - print(f"\n测试过程中发生错误: {e}") - sys.exit(1) diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..51d69cb --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# OGScope 卸载脚本 / OGScope uninstall script +# 从本机移除 systemd 主服务、开机网络单元(若存在)与 drop-in,以及(可选)项目虚拟环境;不卸载 apt 包与全局 Poetry / Removes main service, ogscope-network-boot unit (if any), drop-in, and optional venv; does not remove apt packages or global Poetry +# +# 环境变量 / Environment: +# OGSCOPE_UNINSTALL_CONFIRM=1 — 必须设置,否则脚本退出(防误删)/ Must be set to proceed (safety) +# OGSCOPE_UNINSTALL_KEEP_VENV=1 — 保留项目 .venv / Keep project virtualenv +# OGSCOPE_UNINSTALL_REMOVE_DATA=1 — 同时删除 logs/、uploads/、data/ 下内容(危险)/ Also remove logs, uploads, data (destructive) +# OGSCOPE_UNINSTALL_REMOVE_LEGACY_POETRY_VENV=1 — 删除旧版 Poetry 全局名 venv:~/.virtualenvs/OGScope(若存在)/ Remove legacy Poetry venv at ~/.virtualenvs/OGScope if present + +set -euo pipefail + +if [ "${EUID}" -eq 0 ]; then + echo "❌ 请不要使用 root 用户运行此脚本 / Do not run as root" + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVICE_NAME="ogscope" +SERVICE_PATH="/etc/systemd/system/${SERVICE_NAME}.service" +BOOT_SERVICE_NAME="ogscope-network-boot" +BOOT_SERVICE_PATH="/etc/systemd/system/${BOOT_SERVICE_NAME}.service" +NETWORK_DROPIN="/etc/systemd/system/ogscope.service.d/ogscope-network-env.conf" + +echo "======================================" +echo " OGScope 卸载 / OGScope uninstall" +echo "======================================" +echo "📁 项目目录 / Project: ${PROJECT_DIR}" + +if [ ! -f "${PROJECT_DIR}/pyproject.toml" ]; then + echo "❌ 未找到 pyproject.toml / pyproject.toml not found" + exit 1 +fi + +# 确认 / Confirmation +if [ "${OGSCOPE_UNINSTALL_CONFIRM:-}" != "1" ]; then + if [ -t 0 ] && [ -t 1 ]; then + echo "" + echo "⚠️ 将停止并移除 systemd 服务 ${SERVICE_NAME}、ogscope-network-boot(若存在)与相关 drop-in,并可选删除 .venv。" + echo "⚠️ Will stop and remove ${SERVICE_NAME}, ogscope-network-boot (if present), related drop-ins, and optionally remove .venv." + echo " 数据目录默认保留;设 OGSCOPE_UNINSTALL_REMOVE_DATA=1 可删除 logs/uploads/data。" + echo " Data dirs are kept by default; set OGSCOPE_UNINSTALL_REMOVE_DATA=1 to remove them." + echo "" + read -r -p "输入 YES 继续 / Type YES to continue: " _ans + if [ "${_ans}" != "YES" ]; then + echo "已取消 / Aborted." + exit 0 + fi + else + echo "❌ 非交互环境请设置: OGSCOPE_UNINSTALL_CONFIRM=1 / For non-interactive runs, set OGSCOPE_UNINSTALL_CONFIRM=1" + exit 1 + fi +fi + +cd "${PROJECT_DIR}" + +# 停止并禁用主服务 / Stop and disable main service +echo "🛑 停止服务 / Stopping service..." +sudo systemctl stop "${SERVICE_NAME}" 2>/dev/null || true +sudo systemctl disable "${SERVICE_NAME}" 2>/dev/null || true + +if [ -f "${SERVICE_PATH}" ]; then + echo "🗑️ 移除 unit 文件 / Removing unit file: ${SERVICE_PATH}" + sudo rm -f "${SERVICE_PATH}" +else + echo "ℹ️ 未找到 ${SERVICE_PATH},跳过删除主 unit / Main unit file not found, skipping" +fi + +# 开机网络单元与 drop-in(与 install.sh 对应)/ Boot unit and drop-in (matches install.sh) +if [ -f "${BOOT_SERVICE_PATH}" ]; then + echo "🛑 禁用并移除 ${BOOT_SERVICE_NAME}.service ..." + sudo systemctl stop "${BOOT_SERVICE_NAME}.service" 2>/dev/null || true + sudo systemctl disable "${BOOT_SERVICE_NAME}.service" 2>/dev/null || true + sudo rm -f "${BOOT_SERVICE_PATH}" +fi +if [ -f "${NETWORK_DROPIN}" ]; then + echo "🗑️ 移除 systemd drop-in ${NETWORK_DROPIN} ..." + sudo rm -f "${NETWORK_DROPIN}" + sudo rmdir /etc/systemd/system/ogscope.service.d 2>/dev/null || true +fi + +sudo systemctl daemon-reload +echo "✅ systemd 已更新 / systemd reloaded" + +# 虚拟环境 / Virtualenv +if [ "${OGSCOPE_UNINSTALL_KEEP_VENV:-}" = "1" ]; then + echo "ℹ️ 保留 .venv(OGSCOPE_UNINSTALL_KEEP_VENV=1)/ Keeping .venv" +elif [ -d "${PROJECT_DIR}/.venv" ]; then + echo "🗑️ 删除虚拟环境 / Removing .venv..." + rm -rf "${PROJECT_DIR}/.venv" + echo "✅ .venv 已删除 / .venv removed" +else + echo "ℹ️ 无 .venv 目录 / No .venv directory" +fi + +# 旧版安装曾将 venv 放在 ~/.virtualenvs/OGScope,与当前「项目内 .venv」并存易混淆;可选删除 / Legacy global venv name; optional cleanup +if [ "${OGSCOPE_UNINSTALL_REMOVE_LEGACY_POETRY_VENV:-}" = "1" ]; then + _legacy_venv="${HOME}/.virtualenvs/OGScope" + if [ -d "${_legacy_venv}" ]; then + echo "🗑️ 删除遗留 Poetry 虚拟环境 / Removing legacy Poetry venv: ${_legacy_venv}" + rm -rf "${_legacy_venv}" + echo "✅ 已删除 / Removed" + else + echo "ℹ️ 无 ${_legacy_venv} / No legacy venv at that path" + fi +else + echo "ℹ️ 若存在旧路径 ~/.virtualenvs/OGScope,可设 OGSCOPE_UNINSTALL_REMOVE_LEGACY_POETRY_VENV=1 一并删除 / Optional: remove legacy ~/.virtualenvs/OGScope" +fi + +# 用户数据(可选)/ Optional user data +if [ "${OGSCOPE_UNINSTALL_REMOVE_DATA:-}" = "1" ]; then + echo "🗑️ 删除 logs、uploads、data(OGSCOPE_UNINSTALL_REMOVE_DATA=1)..." + echo "🗑️ Removing logs, uploads, data..." + rm -rf "${PROJECT_DIR}/logs" "${PROJECT_DIR}/uploads" "${PROJECT_DIR}/data" 2>/dev/null || true + echo "✅ 数据目录已清理 / Data dirs removed" +else + echo "ℹ️ 保留 logs/、uploads/、data/(不设 REMOVE_DATA 则保留)/ Keeping logs, uploads, data" +fi + +echo "" +echo "======================================" +echo " ✅ 卸载完成 / Uninstall done" +echo "======================================" +echo "未移除:系统 apt 包、python3-picamera2、全局 Poetry / Not removed: apt packages, picamera2, global Poetry" +echo "若需重装:./scripts/install.sh / To reinstall: ./scripts/install.sh" +echo "" diff --git a/tests/conftest.py b/tests/conftest.py index 1e6ff79..60e996b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,9 @@ """ Pytest 配置和共享 fixtures """ + +from pathlib import Path + import pytest from fastapi.testclient import TestClient @@ -9,20 +12,141 @@ @pytest.fixture def client(): - """FastAPI 测试客户端""" + """FastAPI 测试客户端 / FastAPI test client""" return TestClient(app) @pytest.fixture -def mock_camera(): - """模拟相机""" - # TODO: 实现模拟相机 - pass +def temp_debug_dir(monkeypatch, tmp_path: Path): + """将调试目录重定向到临时目录,避免污染用户目录。 / Redirect the debug directory to a temporary directory to avoid polluting the user directory.""" + from ogscope.web.api.debug import services as debug_services + + debug_root = tmp_path / "dev_captures" + debug_root.mkdir(parents=True, exist_ok=True) + + monkeypatch.setattr(debug_services, "DEBUG_CAPTURES_DIR", debug_root) + monkeypatch.setattr(debug_services, "is_recording", False) + monkeypatch.setattr(debug_services, "recording_task", None) + monkeypatch.setattr(debug_services, "recording_stem", None) + monkeypatch.setattr(debug_services, "recording_t0_mono", None) + monkeypatch.setattr(debug_services, "recording_fps_value", 15.0) + monkeypatch.setattr(debug_services, "recording_media_filename", None) + monkeypatch.setattr(debug_services, "recording_codec_fourcc", "mp4v") + monkeypatch.setattr(debug_services, "recording_container", "MP4") + + return debug_root + + +@pytest.fixture +def mock_plate_solve(monkeypatch): + """避免测试依赖 default_database.npz / Avoid tests requiring default_database.npz.""" + + def _fake_solve(self, stars, frame_shape, **kwargs): + from ogscope.algorithms.plate_solve.solver import SolveResult + + return SolveResult( + ra_deg=12.0, + dec_deg=80.0, + detected_stars=len(stars), + solve_source="full", + status="MATCH_FOUND", + status_code=1, + roll_deg=0.0, + fov_deg=16.0, + matches=min(8, len(stars)), + prob=0.001, + rmse_arcsec=10.0, + t_solve_ms=5.0, + t_extract_ms=None, + t_preprocess_ms=1.0, + raw={}, + solve_overlay={ + "frame_shape": [480, 640], + "stars_matched": [ + { + "x": 100.0, + "y": 200.0, + "ra_deg": 12.0, + "dec_deg": 80.0, + "mag": 5.2, + }, + ], + "stars_pattern": [{"x": 110.0, "y": 210.0}], + "stars_all_centroids": [ + {"x": 100.0, "y": 200.0}, + {"x": 300.0, "y": 400.0}, + ], + }, + ) + + def _fake_solve_from_bgr(self, frame_bgr, max_stars, **kwargs): + from ogscope.algorithms.plate_solve.solver import SolveResult + + return SolveResult( + ra_deg=12.0, + dec_deg=80.0, + detected_stars=8, + solve_source="full", + status="MATCH_FOUND", + status_code=1, + roll_deg=0.0, + fov_deg=16.0, + matches=6, + prob=0.001, + rmse_arcsec=10.0, + t_solve_ms=5.0, + t_extract_ms=1.0, + t_preprocess_ms=1.0, + raw={}, + solve_overlay={ + "frame_shape": [480, 640], + "stars_matched": [ + { + "x": 320.0, + "y": 240.0, + "ra_deg": 12.0, + "dec_deg": 80.0, + "mag": 4.5, + }, + ], + "stars_pattern": [{"x": 315.0, "y": 235.0}], + "stars_all_centroids": [{"x": 320.0, "y": 240.0}], + }, + ) + + monkeypatch.setattr( + "ogscope.algorithms.plate_solve.solver.PlateSolver.solve", + _fake_solve, + ) + monkeypatch.setattr( + "ogscope.algorithms.plate_solve.solver.PlateSolver.solve_from_bgr_frame", + _fake_solve_from_bgr, + ) @pytest.fixture -def sample_image(): - """示例图像""" - # TODO: 提供测试用图像 - pass +def temp_analysis_dir(tmp_path: Path): + """重定向分析目录到临时路径 / Redirect analysis directory to temp path.""" + from ogscope.web.api.analysis.services import analysis_service + + analysis_root = tmp_path / "analysis" + upload_root = analysis_root / "uploads" + jobs_root = analysis_root / "jobs" + results_root = analysis_root / "results" + upload_root.mkdir(parents=True, exist_ok=True) + jobs_root.mkdir(parents=True, exist_ok=True) + results_root.mkdir(parents=True, exist_ok=True) + analysis_service.upload_root = upload_root + analysis_service.jobs_root = jobs_root + analysis_service.results_root = results_root + analysis_service._jobs = {} + # 与实验室清单/实验记录目录一致 / Align lab manifest & experiments with temp dirs + lab = analysis_service._lab + lab.upload_root = upload_root + lab.experiments_root = analysis_root / "experiments" + lab.presets_official = analysis_root / "presets" / "official" + lab.presets_user = analysis_root / "presets" / "user" + for p in (lab.experiments_root, lab.presets_official, lab.presets_user): + p.mkdir(parents=True, exist_ok=True) + return analysis_root diff --git a/tests/integration/test_analysis_pipeline.py b/tests/integration/test_analysis_pipeline.py new file mode 100644 index 0000000..deffe04 --- /dev/null +++ b/tests/integration/test_analysis_pipeline.py @@ -0,0 +1,45 @@ +""" +分析管线集成测试 / Analysis pipeline integration tests +""" + +from pathlib import Path + +import cv2 +import numpy as np +import pytest + + +def _make_frame(path: Path) -> None: + """生成集成测试帧 / Generate integration test frame.""" + frame = np.zeros((300, 420, 3), dtype=np.uint8) + for x, y in [(60, 70), (170, 110), (260, 180), (360, 90), (300, 240)]: + cv2.circle(frame, (x, y), 2, (255, 255, 255), -1) + cv2.imwrite(str(path), frame) + + +@pytest.mark.integration +def test_end_to_end_image_analysis( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """验证上传与单图解算全链路 / Validate upload to single-image solving.""" + image = tmp_path / "integration_stars.jpg" + _make_frame(image) + with image.open("rb") as f: + resp_upload = client.post( + "/api/analysis/upload", + files={"file": ("integration_stars.jpg", f, "image/jpeg")}, + ) + assert resp_upload.status_code == 200 + + resp_solve = client.post( + "/api/analysis/solve/image", + json={ + "input_name": "integration_stars.jpg", + "hint_ra_deg": 31.0, + "hint_dec_deg": 88.0, + }, + ) + assert resp_solve.status_code == 200 + payload = resp_solve.json() + assert payload["success"] is True + assert payload["result"]["solve_source"] == "full" diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py new file mode 100644 index 0000000..f9ecaf1 --- /dev/null +++ b/tests/unit/test_analysis_api.py @@ -0,0 +1,575 @@ +""" +分析 API 测试 / Analysis API tests +""" + +import json +import time +from pathlib import Path + +import cv2 +import numpy as np +import pytest + + +def _build_star_image(path: Path) -> None: + """生成测试星图 / Build test star image.""" + frame = np.zeros((320, 480, 3), dtype=np.uint8) + points = [(120, 80), (200, 150), (330, 200), (400, 100), (250, 260)] + for x, y in points: + cv2.circle(frame, (x, y), 2, (255, 255, 255), -1) + cv2.imwrite(str(path), frame) + + +def _build_test_video(path: Path) -> None: + """生成测试视频 / Build test video.""" + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + writer = cv2.VideoWriter(str(path), fourcc, 8.0, (320, 240)) + if not writer.isOpened(): + raise RuntimeError("视频写入器初始化失败 / Failed to initialize video writer") + for i in range(12): + frame = np.zeros((240, 320, 3), dtype=np.uint8) + cx = 80 + (i * 4) + cy = 90 + (i * 2) + cv2.circle(frame, (cx, cy), 2, (255, 255, 255), -1) + cv2.circle(frame, (200, 180), 2, (255, 255, 255), -1) + writer.write(frame) + writer.release() + + +@pytest.mark.unit +def test_analysis_upload_and_single_image_solve( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """测试上传与单图解算 / Test upload and single-image solve.""" + image_path = tmp_path / "stars.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + upload_resp = client.post( + "/api/analysis/upload", + files={"file": ("stars.jpg", f, "image/jpeg")}, + ) + assert upload_resp.status_code == 200 + assert upload_resp.json()["filename"] == "stars.jpg" + + list_resp = client.get("/api/analysis/uploads") + assert list_resp.status_code == 200 + payload = list_resp.json() + assert "upload_dir" in payload + assert "files" in payload + names = [f["filename"] for f in payload["files"]] + assert "stars.jpg" in names + + file_resp = client.get( + "/api/analysis/uploads/file", params={"filename": "stars.jpg"} + ) + assert file_resp.status_code == 200 + assert len(file_resp.content) > 0 + + solve_resp = client.post( + "/api/analysis/solve/image", + json={ + "input_name": "stars.jpg", + "hint_ra_deg": 45.0, + "hint_dec_deg": 70.0, + "centroid": {"sigma": 2.5, "max_area": 400}, + }, + ) + assert solve_resp.status_code == 200 + solve_data = solve_resp.json() + assert solve_data["success"] is True + result = solve_data["result"] + assert "ra_deg" in result + assert "dec_deg" in result + assert "status" in result + + +@pytest.mark.unit +def test_analysis_extract_preview( + client, temp_analysis_dir, monkeypatch, tmp_path: Path +): + """提星掩膜预览接口 / Extract preview endpoint smoke test.""" + image_path = tmp_path / "stars2.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + upload_resp = client.post( + "/api/analysis/upload", + files={"file": ("stars2.jpg", f, "image/jpeg")}, + ) + assert upload_resp.status_code == 200 + + def _fake_preview(*_a: object, **_kw: object) -> dict: + return { + "success": True, + "detected_stars": 5, + "t_extract_ms": 10.0, + "binary_mask_png_base64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "solve_width": 320, + "solve_height": 480, + "original_width": 320, + "original_height": 480, + } + + monkeypatch.setattr( + "ogscope.web.api.analysis.services.centroid_extraction_preview", + _fake_preview, + ) + prev_resp = client.post( + "/api/analysis/extract/preview", + json={"input_name": "stars2.jpg", "max_image_side": 2048}, + ) + assert prev_resp.status_code == 200 + data = prev_resp.json() + assert data.get("success") is True + assert data.get("detected_stars") == 5 + assert data.get("t_extract_ms") == 10.0 + assert data.get("binary_mask_png_base64") + + +@pytest.mark.unit +def test_analysis_video_job( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """测试视频任务分析 / Test video job analysis.""" + video_path = tmp_path / "stars.mp4" + _build_test_video(video_path) + with video_path.open("rb") as f: + upload_resp = client.post( + "/api/analysis/upload", + files={"file": ("stars.mp4", f, "video/mp4")}, + ) + assert upload_resp.status_code == 200 + + job_resp = client.post( + "/api/analysis/jobs", + json={ + "input_name": "stars.mp4", + "input_type": "video", + "hint_ra_deg": 22.0, + "hint_dec_deg": 84.0, + "frame_step": 2, + "max_frames": 6, + }, + ) + assert job_resp.status_code == 200 + job_data = job_resp.json() + assert job_data["status"] == "succeeded" + + status_resp = client.get(f"/api/analysis/jobs/{job_data['job_id']}") + assert status_resp.status_code == 200 + assert status_resp.json()["status"] == "succeeded" + + result_resp = client.get(f"/api/analysis/jobs/{job_data['job_id']}/result") + assert result_resp.status_code == 200 + result_data = result_resp.json() + assert result_data["job_id"] == job_data["job_id"] + assert len(result_data["results"]) > 0 + + +@pytest.mark.unit +def test_analysis_list_presets_and_batch( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """预设列表与批量解算 / Presets list and batch solve.""" + image_path = tmp_path / "batch.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + up = client.post( + "/api/analysis/upload", + files={"file": ("batch.jpg", f, "image/jpeg")}, + ) + assert up.status_code == 200 + + pr = client.get("/api/analysis/presets", params={"scope": "user"}) + assert pr.status_code == 200 + assert "presets" in pr.json() + + create = client.post( + "/api/analysis/presets", + json={ + "name": "test-preset", + "params": {"fov_estimate": 16.0, "solve_timeout_ms": 8000}, + }, + ) + assert create.status_code == 200 + pid = create.json()["id"] + + batch = client.post( + "/api/analysis/solve/batch", + json={ + "input_name": "batch.jpg", + "runs": [ + {"label": "A", "params": {"fov_estimate": 16.0}}, + {"label": "B", "params": {"fov_estimate": 15.0}}, + ], + }, + ) + assert batch.status_code == 200 + bj = batch.json() + assert bj["input_name"] == "batch.jpg" + assert len(bj["results"]) == 2 + + exp = client.post( + "/api/analysis/experiments", + json={ + "input_name": "batch.jpg", + "preset_label": "A", + "result_json": {"ok": True}, + "metrics": {"matches": 1}, + }, + ) + assert exp.status_code == 200 + + el = client.get("/api/analysis/experiments", params={"page": 1, "page_size": 10}) + assert el.status_code == 200 + assert el.json()["total"] >= 1 + + dl = client.delete(f"/api/analysis/presets/{pid}") + assert dl.status_code == 200 + + +@pytest.mark.unit +def test_analysis_upload_file_info_sidecar(client, temp_analysis_dir, tmp_path: Path): + """上传素材 info 接口合并 stem.txt / Upload info merges sidecar JSON.""" + image_path = tmp_path / "cap.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + up = client.post( + "/api/analysis/upload", + files={"file": ("cap.jpg", f, "image/jpeg")}, + ) + assert up.status_code == 200 + side = temp_analysis_dir / "uploads" / "cap.txt" + side.write_text( + '{"camera": {"exposure_us": 5000, "output_width": 640, "output_height": 480}}', + encoding="utf-8", + ) + info_resp = client.get("/api/analysis/uploads/cap.jpg/info") + assert info_resp.status_code == 200 + data = info_resp.json() + assert data.get("exposure_us") == 5000 + assert "640x480" in str(data.get("resolution", "")) + + +@pytest.mark.unit +def test_analysis_delete_upload_and_experiment( + client, temp_analysis_dir, tmp_path: Path +): + """删除素材与实验记录 / Delete upload and experiment.""" + image_path = tmp_path / "del.jpg" + _build_star_image(image_path) + with image_path.open("rb") as f: + up = client.post( + "/api/analysis/upload", + files={"file": ("del.jpg", f, "image/jpeg")}, + ) + assert up.status_code == 200 + assert (temp_analysis_dir / "uploads" / "del.jpg").is_file() + + dr = client.delete("/api/analysis/uploads/del.jpg") + assert dr.status_code == 200 + assert not (temp_analysis_dir / "uploads" / "del.jpg").is_file() + + exp = client.post( + "/api/analysis/experiments", + json={ + "input_name": "x.jpg", + "preset_label": "t", + "result_json": {"ok": True}, + "metrics": {"matches": 0}, + }, + ) + assert exp.status_code == 200 + eid = exp.json()["id"] + er = client.delete(f"/api/analysis/experiments/{eid}") + assert er.status_code == 200 + assert not (temp_analysis_dir / "experiments" / f"{eid}.json").is_file() + + +@pytest.mark.unit +def test_analysis_solve_video_frame_overlay_ext( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """单帧视频解算返回扩展叠加字段 / Frame solve returns overlay extension.""" + video_path = tmp_path / "frame_ext.mp4" + _build_test_video(video_path) + with video_path.open("rb") as f: + up = client.post( + "/api/analysis/upload", + files={"file": ("frame_ext.mp4", f, "video/mp4")}, + ) + assert up.status_code == 200 + + resp = client.post( + "/api/analysis/solve/frame", + json={ + "source": "file", + "input_name": "frame_ext.mp4", + "time_sec": 0.1, + "overlay_topn_count": 2, + "enable_polar_guide": True, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + row = data.get("result") or {} + ext = row.get("overlay_ext") or {} + labels = ext.get("labels_topn") or [] + assert isinstance(labels, list) + assert len(labels) >= 1 + assert "name" in labels[0] + guide = ext.get("polar_guide") + assert isinstance(guide, dict) + assert "delta_px" in guide + + +@pytest.mark.unit +def test_analysis_solve_video_frame_from_debug_capture( + client, temp_analysis_dir, mock_plate_solve, monkeypatch, tmp_path: Path +): + """调试录制目录的视频也可直接单帧解算 / Frame solve supports debug-capture videos.""" + video_path = tmp_path / "dev_captures" / "debug_cam.mp4" + video_path.parent.mkdir(parents=True, exist_ok=True) + _build_test_video(video_path) + monkeypatch.setattr("ogscope.web.api.analysis.services.Path.home", lambda: tmp_path) + + resp = client.post( + "/api/analysis/solve/frame", + json={ + "source": "file", + "input_name": "debug_cam.mp4", + "time_sec": 0.0, + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + result = data.get("result") or {} + assert "status" in result + + +@pytest.mark.unit +def test_analysis_solve_frame_upload_endpoint( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """浏览器提帧上传接口可解算 / Browser frame-upload endpoint solves frame.""" + image_path = tmp_path / "frame_upload.jpg" + _build_star_image(image_path) + payload = { + "hint_ra_deg": 45.0, + "hint_dec_deg": 75.0, + "solve_profile": "balanced", + "overlay_topn_count": 2, + "enable_polar_guide": True, + } + with image_path.open("rb") as f: + resp = client.post( + "/api/analysis/solve/frame_upload", + files={"file": ("frame.jpg", f, "image/jpeg")}, + data={"payload": json.dumps(payload)}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + row = data.get("result") or {} + assert "status" in row + ext = row.get("overlay_ext") or {} + assert "labels_topn" in ext + + +@pytest.mark.unit +def test_analysis_realtime_gate_skips_by_interval( + client, temp_analysis_dir, mock_plate_solve, tmp_path: Path +): + """连续请求在最小间隔内会被跳过 / Consecutive calls are skipped by interval gate.""" + video_path = tmp_path / "gate_interval.mp4" + _build_test_video(video_path) + with video_path.open("rb") as f: + up = client.post( + "/api/analysis/upload", + files={"file": ("gate_interval.mp4", f, "video/mp4")}, + ) + assert up.status_code == 200 + + first = client.post( + "/api/analysis/solve/frame", + json={ + "source": "file", + "input_name": "gate_interval.mp4", + "time_sec": 0.1, + "solve_interval_ms": 4000, + }, + ) + assert first.status_code == 200 + assert first.json().get("gate_status") == "SOLVED" + + second = client.post( + "/api/analysis/solve/frame", + json={ + "source": "file", + "input_name": "gate_interval.mp4", + "time_sec": 0.1, + "solve_interval_ms": 4000, + }, + ) + assert second.status_code == 200 + data2 = second.json() + assert data2.get("gate_status") == "SKIPPED_INTERVAL" + assert isinstance(data2.get("next_allowed_in_ms"), int) + + +@pytest.mark.unit +def test_analysis_realtime_timeout_releases_gate( + client, temp_analysis_dir, mock_plate_solve, monkeypatch, tmp_path: Path +): + """超时后门禁释放,后续请求可恢复 / Timeout releases gate for following requests.""" + from ogscope.config import get_settings as cfg_get_settings + from ogscope.web.api.analysis.services import analysis_service + + image_path = tmp_path / "gate_timeout.jpg" + _build_star_image(image_path) + base = cfg_get_settings() + # model_copy 保证合法值;直接 setattr 可能被 Pydantic 模型拒绝 / model_copy applies validated values. + low_timeout_settings = base.model_copy( + update={ + "star_analysis_request_timeout_ms": 500, + "star_analysis_min_interval_ms": 500, + "star_analysis_max_interval_ms": 20000, + } + ) + + def _settings_for_timeout_test(): + return low_timeout_settings + + # 须 patch 本模块内名字:「from config import get_settings」不会随 config.get_settings 替换而更新 + # Must patch the name in this module: "from config import get_settings" does not track config replacement. + import ogscope.web.api.analysis.services as analysis_services_mod + + monkeypatch.setattr( + analysis_services_mod, "get_settings", _settings_for_timeout_test + ) + gate = analysis_service._realtime_gate_states.get("file_upload") + if gate is not None: + gate.in_flight = False + gate.last_finished_mono = 0.0 + + def _slow(*_args, **_kwargs): + # hard_timeout = max(0.2, 500ms)=0.5s;须明显超过 / Must exceed 0.5s hard timeout. + time.sleep(0.65) + return { + "frame_index": 0, + "status": "MATCH_FOUND", + "ra_deg": 1.0, + "dec_deg": 2.0, + "solve_overlay": {}, + } + + monkeypatch.setattr(type(analysis_service), "_solve_bgr_to_row", _slow) + with image_path.open("rb") as f: + resp = client.post( + "/api/analysis/solve/frame_upload", + files={"file": ("frame.jpg", f, "image/jpeg")}, + data={"payload": json.dumps({"solve_interval_ms": 50})}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("gate_status") == "TIMEOUT_RELEASED" + + # 满足 effective_interval_ms(≥500)再发第二请求;勿再 patch time.monotonic(会破坏 asyncio 超时) + # Satisfy effective interval (≥500ms) before second request; do not patch time.monotonic (breaks asyncio timeouts). + time.sleep(0.55) + + def _fast(*_args, **_kwargs): + return { + "frame_index": 0, + "status": "MATCH_FOUND", + "ra_deg": 1.0, + "dec_deg": 2.0, + "solve_overlay": {}, + } + + monkeypatch.setattr(type(analysis_service), "_solve_bgr_to_row", _fast) + with image_path.open("rb") as f: + resp2 = client.post( + "/api/analysis/solve/frame_upload", + files={"file": ("frame.jpg", f, "image/jpeg")}, + data={"payload": json.dumps({"solve_interval_ms": 50})}, + ) + assert resp2.status_code == 200 + assert resp2.json().get("gate_status") == "SOLVED" + + +@pytest.mark.unit +def test_analysis_camera_solve_skipped_when_recording_active( + client, temp_analysis_dir, mock_plate_solve, monkeypatch +): + """录制进行中时拒绝实时相机解算 / Reject camera solve when recording is active.""" + monkeypatch.setattr( + "ogscope.web.api.debug.services.is_recording_active", lambda: True + ) + resp = client.post( + "/api/analysis/solve/frame", + json={"source": "camera"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("gate_status") == "SKIPPED_BUSY" + assert "recording" in str(data.get("gate_reason", "")) + + +@pytest.mark.unit +def test_analysis_replace_transcoded_video_updates_sidecar( + client, temp_analysis_dir, tmp_path: Path +): + """转码替换后删除旧 AVI 并更新侧车 / Replace transcoded video deletes old AVI and updates sidecar.""" + avi_path = tmp_path / "raw.avi" + avi_path.write_bytes(b"AVI") + mp4_path = tmp_path / "out.mp4" + mp4_path.write_bytes(b"MP4") + with avi_path.open("rb") as f: + up_avi = client.post( + "/api/analysis/upload", + files={"file": ("raw.avi", f, "video/x-msvideo")}, + ) + assert up_avi.status_code == 200 + with mp4_path.open("rb") as f: + up_mp4 = client.post( + "/api/analysis/upload", + files={"file": ("out.mp4", f, "video/mp4")}, + ) + assert up_mp4.status_code == 200 + + side = temp_analysis_dir / "uploads" / "raw.txt" + side.write_text( + json.dumps( + { + "kind": "video", + "media_file": "raw.avi", + "extra": {"codec_fourcc": "MJPG", "container": "AVI"}, + } + ), + encoding="utf-8", + ) + + resp = client.post( + "/api/analysis/uploads/replace_video", + json={ + "old_filename": "raw.avi", + "new_filename": "out.mp4", + "duration_s": 3.2, + "nominal_fps": 2.0, + "codec_fourcc": "libx264", + "container": "MP4", + }, + ) + assert resp.status_code == 200 + data = resp.json() + assert data.get("success") is True + assert not (temp_analysis_dir / "uploads" / "raw.avi").exists() + assert (temp_analysis_dir / "uploads" / "out.mp4").is_file() + new_side = temp_analysis_dir / "uploads" / "out.txt" + assert new_side.is_file() + side_obj = json.loads(new_side.read_text(encoding="utf-8")) + assert side_obj.get("media_file") == "out.mp4" + extra = side_obj.get("extra") or {} + assert extra.get("container") == "MP4" diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 8e6325d..d8df904 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -1,34 +1,72 @@ """ Web API 单元测试 """ + import pytest @pytest.mark.unit def test_root(client): - """测试根路径""" + """测试根路径返回 HTML 页面。 / Testing the root path returns an HTML page.""" response = client.get("/") assert response.status_code == 200 - data = response.json() - assert data["name"] == "OGScope" - assert "version" in data + assert "text/html" in response.headers.get("content-type", "") + assert "OGScope" in response.text + + +@pytest.mark.unit +def test_debug_analysis_page(client): + """测试星空解算控制台页面。 / Test plate solve console page.""" + response = client.get("/debug/analysis") + assert response.status_code == 200 + assert "text/html" in response.headers.get("content-type", "") + assert "星空解算" in response.text @pytest.mark.unit def test_health_check(client): - """测试健康检查""" + """测试健康检查接口。 / Test the health check interface.""" response = client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "healthy" + assert "version" in data + + +@pytest.mark.unit +def test_app_api_root(client): + """测试应用级 / Test application level""" + response = client.get("/api") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "running" + assert data["docs"] == "/docs" @pytest.mark.unit def test_camera_status(client): - """测试获取相机状态""" + """测试获取相机状态接口结构。 / Test to obtain the camera status interface structure.""" response = client.get("/api/camera/status") assert response.status_code == 200 data = response.json() assert "connected" in data assert "streaming" in data + assert "mode" in data + +@pytest.mark.unit +def test_system_info(client): + """测试系统信息接口结构。 / Test system info endpoint schema.""" + response = client.get("/api/system/info") + assert response.status_code == 200 + data = response.json() + assert "platform" in data + assert "os" in data + assert "cpu_usage" in data + assert "memory_usage" in data + assert "temperature" in data + assert "wifi_quality" in data + assert "wifi_signal_dbm" in data + assert "wifi_interface" in data + assert "uptime_seconds" in data + assert "load_average_1m" in data diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py new file mode 100644 index 0000000..3346a4f --- /dev/null +++ b/tests/unit/test_debug_camera_api.py @@ -0,0 +1,254 @@ +""" +调试相机 API 的第二层最小测试网(无真实硬件依赖)。 +""" + +import numpy as np +import pytest + + +class FakeCamera: + def __init__(self): + self.is_initialized = True + self.is_capturing = False + self.width = 640 + self.height = 360 + self.output_width = 640 + self.output_height = 360 + self.capture_width = 1280 + self.capture_height = 720 + self.fps = 5 + self.sampling_mode = "supersample" + self.rotation = 180 + self.auto_exposure = True + self.white_balance_mode = "auto" + self.exposure_us = 10000 + self.analogue_gain = 1.0 + self.digital_gain = 1.0 + + def get_camera_info(self): + return { + "width": self.width, + "height": self.height, + "output_width": self.output_width, + "output_height": self.output_height, + "capture_width": self.capture_width, + "capture_height": self.capture_height, + "fps": self.fps, + "sampling_mode": self.sampling_mode, + "rotation": self.rotation, + "auto_exposure": self.auto_exposure, + "white_balance_mode": self.white_balance_mode, + "exposure_us": self.exposure_us, + "analogue_gain": self.analogue_gain, + "digital_gain": self.digital_gain, + } + + def start_capture(self): + self.is_capturing = True + return True + + def stop_capture(self): + self.is_capturing = False + return True + + def set_rotation(self, rotation): + self.rotation = rotation + return True + + def set_fps(self, fps): + self.fps = int(fps) + return True + + def set_sampling_mode(self, mode): + self.sampling_mode = mode + return True + + def set_resolution(self, width, height, fps=None): + self.output_width = int(width) + self.output_height = int(height) + self.width = int(width) + self.height = int(height) + if fps is not None: + self.fps = int(fps) + return True + + def get_image_quality_metrics(self): + return {"noise_level": 0.1, "exposure_adequacy": 0.9} + + def set_auto_exposure(self, enabled): + self.auto_exposure = bool(enabled) + return True + + def set_exposure(self, exposure): + self.exposure_us = int(exposure) + return True + + def set_gain(self, analogue, digital=1.0): + self.analogue_gain = float(analogue) + self.digital_gain = float(digital) + return True + + def set_image_enhancement(self, contrast, brightness, saturation, sharpness): + return True + + def set_noise_reduction(self, level): + return True + + def set_white_balance(self, mode, gain_r=1.0, gain_b=1.0): + self.white_balance_mode = mode + return True + + def set_color_mode(self, color_mode): + return True + + def set_night_mode(self, enabled): + return True + + def get_video_frame(self): + """与 CameraManager 抓帧路径一致 / Matches CameraManager grabber path.""" + return np.zeros((self.output_height, self.output_width, 3), dtype=np.uint8) + + def capture_image(self): + return self.get_video_frame() + + +@pytest.fixture +def fake_camera_env(monkeypatch, temp_debug_dir): + from ogscope.web.api.debug import services as debug_services + from ogscope.web.camera_shared import get_camera_manager + + camera = FakeCamera() + + def _get_camera_instance(): + return camera + + async def _noop(): + return None + + get_camera_manager().attach_camera_instance(camera) + monkeypatch.setattr(debug_services, "get_camera_instance", _get_camera_instance) + monkeypatch.setattr( + debug_services.DebugCameraService, + "_ensure_preview_grabber", + staticmethod(_noop), + ) + monkeypatch.setattr( + debug_services.DebugCameraService, "_stop_preview_grabber", staticmethod(_noop) + ) + monkeypatch.setattr( + debug_services.DebugCameraService, + "_restart_preview_grabber", + staticmethod(_noop), + ) + return camera + + +@pytest.mark.unit +def test_debug_camera_status_with_fake_camera(client, fake_camera_env): + response = client.get("/api/debug/camera/status") + assert response.status_code == 200 + data = response.json() + assert data["connected"] is True + assert data["streaming"] is False + assert "info" in data + + +@pytest.mark.unit +def test_debug_camera_start_and_stop(client, fake_camera_env): + start_resp = client.post("/api/debug/camera/start") + assert start_resp.status_code == 200 + assert start_resp.json()["success"] is True + + stop_resp = client.post("/api/debug/camera/stop") + assert stop_resp.status_code == 200 + assert stop_resp.json()["success"] is True + + +@pytest.mark.unit +def test_debug_camera_rotation(client, fake_camera_env): + response = client.post("/api/debug/camera/rotation/90") + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["message_key"] == "server.rotationSet" + + +@pytest.mark.unit +def test_debug_camera_fps_validation(client): + response = client.post("/api/debug/camera/fps", params={"fps": 0}) + assert response.status_code == 422 + + +@pytest.mark.unit +def test_debug_camera_fps_success(client, fake_camera_env): + response = client.post("/api/debug/camera/fps", params={"fps": 12}) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["info"]["fps"] == 12 + + +@pytest.mark.unit +def test_debug_camera_sampling_mode_success(client, fake_camera_env): + response = client.post("/api/debug/camera/sampling", params={"mode": "native"}) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["info"]["sampling_mode"] == "native" + + +@pytest.mark.unit +def test_debug_camera_image_quality_success(client, fake_camera_env): + response = client.get("/api/debug/camera/image-quality") + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert "quality" in body + assert body["quality"]["noise_level"] == 0.1 + + +@pytest.mark.unit +def test_debug_camera_update_settings_success(client, fake_camera_env): + payload = { + "exposure": 12000, + "gain": 1.5, + "autoExposure": False, + "digitalGain": 1.2, + "contrast": 1.1, + "brightness": 0.1, + "saturation": 1.0, + "sharpness": 1.0, + "noiseReduction": 1, + "whiteBalanceMode": "auto", + "whiteBalanceGainR": 1.0, + "whiteBalanceGainB": 1.0, + "colorMode": "color", + } + + response = client.post("/api/debug/camera/settings", json=payload) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["settings"]["exposure"] == 12000 + + +@pytest.mark.unit +def test_debug_camera_auto_exposure_switch_success(client, fake_camera_env): + response = client.post("/api/debug/camera/auto-exposure", params={"enabled": False}) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert body["auto_exposure"] is False + assert fake_camera_env.auto_exposure is False + + +@pytest.mark.unit +def test_debug_camera_white_balance_switch_success(client, fake_camera_env): + response = client.post( + "/api/debug/camera/white-balance", + params={"mode": "night", "gain_r": 1.0, "gain_b": 1.0}, + ) + assert response.status_code == 200 + body = response.json() + assert body["success"] is True + assert fake_camera_env.white_balance_mode == "night" diff --git a/tests/unit/test_debug_files_api.py b/tests/unit/test_debug_files_api.py new file mode 100644 index 0000000..cb61f3a --- /dev/null +++ b/tests/unit/test_debug_files_api.py @@ -0,0 +1,57 @@ +""" +调试文件 API 的最小回归测试。 +""" + +import json + +import pytest + + +@pytest.mark.unit +def test_debug_files_empty(client, temp_debug_dir): + response = client.get("/api/debug/files") + assert response.status_code == 200 + assert response.json() == {"files": []} + + +@pytest.mark.unit +def test_debug_files_list_and_info(client, temp_debug_dir): + image_name = "IMG_20260101_000000.jpg" + info_name = "IMG_20260101_000000.txt" + + (temp_debug_dir / image_name).write_bytes(b"fake-image-bytes") + (temp_debug_dir / info_name).write_text( + json.dumps({"exposure_us": 10000, "analogue_gain": 1.0}, ensure_ascii=False), + encoding="utf-8", + ) + + files_resp = client.get("/api/debug/files") + assert files_resp.status_code == 200 + files = files_resp.json()["files"] + assert len(files) == 1 + assert files[0]["name"] == image_name + assert files[0]["type"] == "image" + + info_resp = client.get(f"/api/debug/files/{image_name}/info") + assert info_resp.status_code == 200 + info = info_resp.json() + assert info["filename"] == image_name + assert info["type"] == "image" + assert info["exposure_us"] == 10000 + + +@pytest.mark.unit +def test_debug_files_delete_removes_image_and_info(client, temp_debug_dir): + image_name = "IMG_20260101_000001.jpg" + info_name = "IMG_20260101_000001.txt" + + image_path = temp_debug_dir / image_name + info_path = temp_debug_dir / info_name + image_path.write_bytes(b"fake-image-bytes") + info_path.write_text("{}", encoding="utf-8") + + delete_resp = client.delete(f"/api/debug/files/{image_name}") + assert delete_resp.status_code == 200 + assert "message_key" in delete_resp.json() + assert not image_path.exists() + assert not info_path.exists() diff --git a/tests/unit/test_debug_presets_api.py b/tests/unit/test_debug_presets_api.py new file mode 100644 index 0000000..8781a92 --- /dev/null +++ b/tests/unit/test_debug_presets_api.py @@ -0,0 +1,77 @@ +""" +调试预设 API 的最小回归测试。 +""" + +import pytest + + +def _sample_preset(name: str = "night-sky") -> dict: + return { + "name": name, + "description": "test preset", + "exposure_us": 20000, + "analogue_gain": 2.0, + "digital_gain": 1.0, + "auto_exposure": False, + "auto_gain": False, + "contrast": 1.0, + "brightness": 0.0, + "saturation": 1.0, + "sharpness": 1.0, + "noise_reduction": 0, + "white_balance_mode": "auto", + "white_balance_gain_r": 1.0, + "white_balance_gain_b": 1.0, + "rotation": 180, + "color_mode": "color", + } + + +@pytest.mark.unit +def test_debug_presets_empty(client, temp_debug_dir): + response = client.get("/api/debug/camera/presets") + assert response.status_code == 200 + assert response.json() == {"presets": []} + + +@pytest.mark.unit +def test_debug_presets_save_and_get(client, temp_debug_dir): + payload = _sample_preset() + save_resp = client.post("/api/debug/camera/presets", json=payload) + assert save_resp.status_code == 200 + assert save_resp.json()["success"] is True + + get_resp = client.get("/api/debug/camera/presets") + assert get_resp.status_code == 200 + presets = get_resp.json()["presets"] + assert len(presets) == 1 + assert presets[0]["name"] == payload["name"] + + +@pytest.mark.unit +def test_debug_presets_update_same_name(client, temp_debug_dir): + first = _sample_preset("deep-sky") + second = _sample_preset("deep-sky") + second["exposure_us"] = 30000 + + assert client.post("/api/debug/camera/presets", json=first).status_code == 200 + assert client.post("/api/debug/camera/presets", json=second).status_code == 200 + + get_resp = client.get("/api/debug/camera/presets") + presets = get_resp.json()["presets"] + assert len(presets) == 1 + assert presets[0]["exposure_us"] == 30000 + + +@pytest.mark.unit +def test_debug_presets_delete(client, temp_debug_dir): + payload = _sample_preset("to-delete") + assert client.post("/api/debug/camera/presets", json=payload).status_code == 200 + + delete_resp = client.delete("/api/debug/camera/presets/to-delete") + assert delete_resp.status_code == 200 + assert delete_resp.json()["success"] is True + + get_resp = client.get("/api/debug/camera/presets") + assert get_resp.status_code == 200 + assert get_resp.json()["presets"] == [] diff --git a/tests/unit/test_plate_large_scale_bg.py b/tests/unit/test_plate_large_scale_bg.py new file mode 100644 index 0000000..e10f22b --- /dev/null +++ b/tests/unit/test_plate_large_scale_bg.py @@ -0,0 +1,27 @@ +""" +大尺度背景减除单元测试 / Unit tests for large-scale background flattening. +""" + +import numpy as np +import pytest + +from ogscope.algorithms.plate_solve.solver import subtract_large_scale_background_bgr + + +@pytest.mark.unit +def test_subtract_large_scale_background_bgr_shape_and_range() -> None: + """输出与输入同形且值域在 uint8 / Output shape matches and values in uint8 range.""" + h, w = 120, 160 + bgr = np.zeros((h, w, 3), dtype=np.uint8) + bgr[:, :, 1] = np.linspace(0, 80, w, dtype=np.uint8) + out = subtract_large_scale_background_bgr(bgr, downsample_max_side=64) + assert out.shape == bgr.shape + assert out.dtype == np.uint8 + assert int(out.min()) >= 0 and int(out.max()) <= 255 + + +@pytest.mark.unit +def test_subtract_large_scale_background_bgr_non_bgr_passthrough() -> None: + """非三通道图原样返回 / Non-3-channel frames pass through unchanged.""" + gray = np.zeros((10, 10), dtype=np.uint8) + assert subtract_large_scale_background_bgr(gray, downsample_max_side=32) is gray diff --git a/tests/unit/test_realtime_api.py b/tests/unit/test_realtime_api.py new file mode 100644 index 0000000..80c0ebf --- /dev/null +++ b/tests/unit/test_realtime_api.py @@ -0,0 +1,62 @@ +""" +实时解算 API 测试 / Realtime solving API tests +""" + +import asyncio +from dataclasses import dataclass + +import numpy as np +import pytest + + +@dataclass +class _FakeCamera: + """测试相机 / Test camera""" + + is_capturing: bool = True + + def get_video_frame(self): + frame = np.zeros((240, 320, 3), dtype=np.uint8) + frame[120, 160] = (255, 255, 255) + frame[60, 100] = (255, 255, 255) + return frame + + +@pytest.mark.unit +def test_realtime_solver_status_endpoints(client, monkeypatch, mock_plate_solve): + """测试实时解算启停接口 / Test realtime solver start and stop endpoints.""" + from ogscope.web.api.debug import routes as debug_routes + from ogscope.web.camera_shared import CameraManager, get_camera_manager + + fake_camera = _FakeCamera() + get_camera_manager().attach_camera_instance(fake_camera) + monkeypatch.setattr( + debug_routes.DebugCameraService, + "get_camera_instance", + staticmethod(lambda: fake_camera), + ) + + async def _fake_get_raw_frame(_self): + frame = fake_camera.get_video_frame() + return frame, 1, 0.0 + + monkeypatch.setattr(CameraManager, "get_raw_frame", _fake_get_raw_frame) + + start_resp = client.post( + "/api/debug/analysis/realtime/start", + params={"hint_ra_deg": 15.0, "hint_dec_deg": 85.0}, + ) + assert start_resp.status_code == 200 + assert start_resp.json()["success"] is True + + asyncio.run(asyncio.sleep(0.05)) + + status_resp = client.get("/api/debug/analysis/realtime/status") + assert status_resp.status_code == 200 + status_data = status_resp.json() + assert "running" in status_data + assert "frame_count" in status_data + + stop_resp = client.post("/api/debug/analysis/realtime/stop") + assert stop_resp.status_code == 200 + assert stop_resp.json()["success"] is True diff --git a/tests/unit/test_solver_performance_baseline.py b/tests/unit/test_solver_performance_baseline.py new file mode 100644 index 0000000..f89ca9f --- /dev/null +++ b/tests/unit/test_solver_performance_baseline.py @@ -0,0 +1,44 @@ +""" +解算性能基线测试 / Solver performance baseline tests +""" + +from __future__ import annotations + +import time + +import cv2 +import numpy as np +import pytest + +from ogscope.algorithms.star_extract import StarExtractor + + +def _synthetic_frame( + width: int = 640, height: int = 360, stars: int = 60 +) -> np.ndarray: + """生成合成星空帧 / Generate synthetic star field frame.""" + frame = np.zeros((height, width, 3), dtype=np.uint8) + rng = np.random.default_rng(42) + xs = rng.integers(0, width, size=stars) + ys = rng.integers(0, height, size=stars) + for x, y in zip(xs, ys): + cv2.circle(frame, (int(x), int(y)), 1, (255, 255, 255), -1) + return frame + + +@pytest.mark.unit +@pytest.mark.slow +def test_star_extract_performance_baseline(): + """星点提取性能基线(不加载 Tetra 数据库)/ Star extraction baseline without Tetra DB.""" + extractor = StarExtractor(max_stars=80) + frame = _synthetic_frame() + rounds = 40 + + start = time.perf_counter() + for _ in range(rounds): + stars = extractor.extract(frame) + assert len(stars) >= 0 + elapsed = time.perf_counter() - start + avg_ms = (elapsed / rounds) * 1000.0 + + assert avg_ms < 35.0 diff --git a/tests/unit/test_system_wifi_parse.py b/tests/unit/test_system_wifi_parse.py new file mode 100644 index 0000000..83571d8 --- /dev/null +++ b/tests/unit/test_system_wifi_parse.py @@ -0,0 +1,42 @@ +"""WiFi 指标解析:/proc/net/wireless 列顺序 / WiFi metrics column order.""" + +from pathlib import Path + +import pytest + +from ogscope.web.api.system import services as system_services +from ogscope.web.api.system.services import SystemInfoService + +_WIRELESS_SAMPLE = """Inter-| sta-| Quality | Discarded packets + face | tus | link level noise | nwid crypt frag + wlan0: 0000 45. -50. -256 0 0 0 +""" + + +@pytest.mark.unit +def test_read_wifi_metrics_link_not_status_column( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """第一列为 status(0000),link 在 values[1] / First column is status, not link quality.""" + real_path = Path + + def path_new(arg: str) -> object: + if arg == "/proc/net/wireless": + + class _W: + def exists(self) -> bool: + return True + + def read_text(self, encoding: str | None = None) -> str: + return _WIRELESS_SAMPLE + + return _W() + return real_path(arg) + + monkeypatch.setattr(system_services, "Path", path_new) + svc = SystemInfoService(cache_ttl_seconds=0.0) + q, sig, iface = svc._read_wifi_metrics() + assert iface == "wlan0" + assert sig == -50.0 + assert q is not None + assert abs(float(q) - (45.0 / 70.0) * 100.0) < 0.01 diff --git a/tests/unit/test_wifi_switch.py b/tests/unit/test_wifi_switch.py new file mode 100644 index 0000000..2b65a69 --- /dev/null +++ b/tests/unit/test_wifi_switch.py @@ -0,0 +1,163 @@ +""" +WiFi 切换服务与 API 单元测试 / Unit tests for WiFi switch service and API. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from ogscope.config import Settings +from ogscope.hardware.wifi_switch import WifiSwitchService, _parse_status_output + + +@pytest.mark.unit +def test_parse_status_output() -> None: + """解析脚本状态输出 / Parse key=value status output.""" + text = "\n".join( + [ + "MODE=ap", + "ACTIVE_CONNECTION=OGScope-AP", + "WIRELESS_INTERFACE=wlan0", + "AP_IPV4=192.168.4.1/24", + ] + ) + data = _parse_status_output(text) + assert data["MODE"] == "ap" + assert data["ACTIVE_CONNECTION"] == "OGScope-AP" + assert data["AP_IPV4"] == "192.168.4.1/24" + + +@pytest.mark.unit +def test_wifi_service_not_configured(tmp_path: Path) -> None: + """未配置时返回 unknown / Return unknown when not configured.""" + settings = Settings( + wifi_sta_connection="", + wifi_ap_connection="", + wifi_switch_script=tmp_path / "missing-script", + ) + service = WifiSwitchService(settings) + assert service.is_configured() is False + status = service.get_status() + assert status["MODE"] == "unknown" + assert status["error"] == "wifi_not_configured" + + +@pytest.mark.unit +def test_wifi_service_status_and_switch(monkeypatch, tmp_path: Path) -> None: + """配置后可执行 status/switch / Run status and switch when configured.""" + script = tmp_path / "ogscope-wifi-switch" + script.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + script.chmod(0o755) + settings = Settings( + wifi_sta_connection="HOME-STA", + wifi_ap_connection="OGScope-AP", + wifi_switch_script=script, + wifi_switch_use_sudo=False, + wifi_interface="wlan0", + ) + service = WifiSwitchService(settings) + + calls: list[list[str]] = [] + + def _fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd[-1] == "status": + return subprocess.CompletedProcess( + cmd, + 0, + stdout="\n".join( + [ + "MODE=sta", + "ACTIVE_CONNECTION=HOME-STA", + "WIRELESS_INTERFACE=wlan0", + "STA_CONNECTION=HOME-STA", + "AP_CONNECTION=OGScope-AP", + ] + ), + stderr="", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", _fake_run) + status = service.get_status() + assert status["MODE"] == "sta" + service.switch("ap") + service.switch("sta") + assert calls[0][-1] == "status" + assert calls[1][-1] == "ap" + assert calls[2][-1] == "sta" + + +@pytest.mark.unit +def test_network_api(client, monkeypatch) -> None: + """网络 API 状态与切换 / Network API status and switch.""" + from ogscope.web.api.network import routes as network_routes + + monkeypatch.setattr( + network_routes.wifi_switch_service, "is_configured", lambda: True + ) + monkeypatch.setattr( + network_routes.wifi_switch_service, + "get_status", + lambda: { + "MODE": "ap", + "ACTIVE_CONNECTION": "OGScope-AP", + "WIRELESS_INTERFACE": "wlan0", + "STA_CONNECTION": "HOME-STA", + "AP_CONNECTION": "OGScope-AP", + "AP_IPV4": "192.168.4.1/24", + }, + ) + monkeypatch.setattr(network_routes.wifi_switch_service, "switch", lambda mode: None) + + response = client.get("/api/network/wifi") + assert response.status_code == 200 + data = response.json() + assert data["mode"] == "ap" + assert data["active_connection"] == "OGScope-AP" + + response2 = client.post("/api/network/wifi", json={"mode": "sta"}) + assert response2.status_code == 200 + + +@pytest.mark.unit +def test_network_wifi_scan_api(client, monkeypatch) -> None: + """WiFi 扫描 API / WiFi scan API.""" + from ogscope.web.api.network import services as net_services + + monkeypatch.setattr( + net_services, + "nmcli_wifi_scan", + lambda iface: ([{"ssid": "Home", "signal": 80, "security": "WPA2"}], None), + ) + + response = client.get("/api/network/wifi/scan") + assert response.status_code == 200 + data = response.json() + assert "networks" in data + assert len(data["networks"]) >= 1 + assert data["networks"][0]["ssid"] == "Home" + + +@pytest.mark.unit +def test_network_profiles_api(client, monkeypatch) -> None: + """WiFi profiles API / WiFi profiles API.""" + from ogscope.web.api.network import services as net_services + + def _fake_profiles(_settings): + return [ + { + "connection_name": "OGScope-STA", + "ssid": "MyWifi", + "autoconnect": True, + } + ] + + monkeypatch.setattr(net_services, "nmcli_wifi_profiles", _fake_profiles) + response = client.get("/api/network/wifi/profiles") + assert response.status_code == 200 + data = response.json() + assert data["profiles"][0]["connection_name"] == "OGScope-STA" diff --git a/web/analysis-ui/README.md b/web/analysis-ui/README.md new file mode 100644 index 0000000..d0cb231 --- /dev/null +++ b/web/analysis-ui/README.md @@ -0,0 +1,36 @@ +# 星空解算控制台前端 / Plate Solve Console UI + +## 用途 / Purpose + +- 技术栈:**Vite 5 + React 18 + TypeScript + Tailwind CSS**。 +- 入口页面由 FastAPI 在 **`GET /debug/analysis`** 提供:若存在 `web/static/analysis-lab/index.html` 则返回 SPA,否则回退旧版 Jinja 模板。 +- 静态资源由 FastAPI 挂载 **`/static`**,本应用 `base` 为 **`/static/analysis-lab/`**。 +- 文案 i18n:**`web/static/i18n/analysis.zh.json`**、**`analysis.en.json`**;开发时 Vite 将 **`/static`** 代理到 FastAPI 以便加载上述 JSON。 + +## 常用命令 / Commands + +```bash +cd web/analysis-ui +npm install # 安装依赖 / Install deps +npm run dev # 开发服务器(见下)/ Dev server +npm run build # 生产构建,输出到 ../static/analysis-lab/ +``` + +## 构建产物 / Build output + +- 目录:**`web/static/analysis-lab/`**(`index.html` + `assets/`)。 +- 部署到开发板前需包含该目录(本机构建后提交,或由 CI `npm ci && npm run build` 生成)。 + +## 本地联调 / Local API + +- `vite.config.ts` 中配置了 **`/api`** 与 **`/static`** → `http://127.0.0.1:8000` 的代理;需同时启动 OGScope 后端(默认 8000 端口)。 +- 开发时访问地址形如:`http://127.0.0.1:5173/static/analysis-lab/`(以终端输出为准)。 + +## 同步开发板 / Sync to board + +- 脚本:**`scripts/sync_dev_board.sh`**(先 `npm run build`,再 `rsync`)。 +- 环境变量:`OGSCOPE_DEV_HOST`、`OGSCOPE_DEV_PATH`(可选 `OGSCOPE_DEV_USER`)。 + +## CI + +- **`.github/workflows/ci.yml`** 在 pytest 前执行 `web/analysis-ui` 下的 `npm ci` 与 `npm run build`。 diff --git a/web/analysis-ui/camera.html b/web/analysis-ui/camera.html new file mode 100644 index 0000000..f4a0653 --- /dev/null +++ b/web/analysis-ui/camera.html @@ -0,0 +1,12 @@ + + + + + + OGScope 相机调试控制台 + + +
+ + + diff --git a/web/analysis-ui/index.html b/web/analysis-ui/index.html new file mode 100644 index 0000000..9c58106 --- /dev/null +++ b/web/analysis-ui/index.html @@ -0,0 +1,12 @@ + + + + + + OGScope 星空解算控制台 + + +
+ + + diff --git a/web/analysis-ui/package-lock.json b/web/analysis-ui/package-lock.json new file mode 100644 index 0000000..cbbba70 --- /dev/null +++ b/web/analysis-ui/package-lock.json @@ -0,0 +1,2708 @@ +{ + "name": "ogscope-analysis-lab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ogscope-analysis-lab", + "version": "0.1.0", + "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmmirror.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@ffmpeg/ffmpeg": { + "version": "0.12.15", + "resolved": "https://registry.npmmirror.com/@ffmpeg/ffmpeg/-/ffmpeg-0.12.15.tgz", + "integrity": "sha512-1C8Obr4GsN3xw+/1Ww6PFM84wSQAGsdoTuTWPOj2OizsRDLT4CXTaVjPhkw6ARyDus1B9X/L2LiXHqYYsGnRFw==", + "license": "MIT", + "dependencies": { + "@ffmpeg/types": "^0.12.4" + }, + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@ffmpeg/types": { + "version": "0.12.4", + "resolved": "https://registry.npmmirror.com/@ffmpeg/types/-/types-0.12.4.tgz", + "integrity": "sha512-k9vJQNBGTxE5AhYDtOYR5rO5fKsspbg51gbcwtbkw2lCdoIILzklulcjJfIDwrtn7XhDeF2M+THwJ2FGrLeV6A==", + "license": "MIT", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/util": { + "version": "0.12.2", + "resolved": "https://registry.npmmirror.com/@ffmpeg/util/-/util-0.12.2.tgz", + "integrity": "sha512-ouyoW+4JB7WxjeZ2y6KpRvB+dLp7Cp4ro8z0HIVpZVCM7AwFlHa0c4R8Y/a4M3wMqATpYKhC7lSFHQ0T11MEDw==", + "license": "MIT", + "engines": { + "node": ">=18.x" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmmirror.com/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmmirror.com/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.328", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.328.tgz", + "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/web/analysis-ui/package.json b/web/analysis-ui/package.json new file mode 100644 index 0000000..b611089 --- /dev/null +++ b/web/analysis-ui/package.json @@ -0,0 +1,28 @@ +{ + "name": "ogscope-analysis-lab", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ffmpeg/ffmpeg": "^0.12.15", + "@ffmpeg/util": "^0.12.2", + "lucide-react": "^0.460.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.15", + "typescript": "~5.6.2", + "vite": "^5.4.10" + } +} diff --git a/web/analysis-ui/postcss.config.js b/web/analysis-ui/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/analysis-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/analysis-ui/src/AnalysisLabPage.tsx b/web/analysis-ui/src/AnalysisLabPage.tsx new file mode 100644 index 0000000..2aa876a --- /dev/null +++ b/web/analysis-ui/src/AnalysisLabPage.tsx @@ -0,0 +1,2573 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Database, + Grid3x3, + History, + Home, + Loader2, + RefreshCw, + RotateCcw, + Trash2, + Upload, + ZoomIn, + ZoomOut, + ChevronDown, +} from "lucide-react"; +import { + BatchRun, + debugCaptureFileUrl, + deleteExperimentRecord, + deletePoolUpload, + experimentAssetUrl, + exportExperiments, + fetchDebugFileInfo, + fetchDebugFiles, + fetchExperiments, + fetchLabSettings, + fetchPresets, + fetchSystemInfo, + fetchUploadExperimentCount, + fetchUploadFileInfo, + fetchUploads, + importFromDebug, + replaceTranscodedVideo, + saveExperiment, + saveUserPreset, + solveBatch, + solveFrameFromBlob, + solveImage, + solveVideoFrame, + uploadFile, + uploadFileUrl, + type DebugFileRow, + type LabPublicSettings, + type SolveParams, + type UploadFileRow, +} from "./api"; +import { + drawSolveOverlay, + drawSolveOverlayVideo, + type SolveOverlay, +} from "./drawOverlay"; +import { transcodeAviToMp4 } from "./utils/transcode"; +import { useI18n } from "./i18n/I18nProvider"; +import { buildMetaCaptionRows } from "./utils/metaCaption"; +import { formatDateTime, formatFileSize } from "./utils/format"; +import { + formatAngleDeg, + formatProbLine, + parseSolveResult, +} from "./utils/solveDisplay"; + +type View = "lab_image" | "lab_video" | "pool" | "history"; + +const defaultParams = (): SolveParams => ({ + hint_ra_deg: 45, + hint_dec_deg: 80, + fov_estimate: 11, + fov_max_error: undefined, + solve_timeout_ms: 1500, + solve_profile: "balanced", + max_image_side: 1600, + large_scale_bg_subtract: false, + detail_level: "summary", + centroid: { + sigma: 2.5, + max_area: 400, + min_area: 5, + filtsize: 25, + binary_open: true, + max_axis_ratio: undefined, + }, +}); + +function metricsFromResult(result: Record | null): Record { + if (!result) return {}; + return { + matches: result.matches, + rmse_arcsec: result.rmse_arcsec, + status: result.status, + prob: result.prob, + t_solve_ms: result.t_solve_ms, + }; +} + +function countStarsFromOverlay( + result: Record | null, +): number | null { + if (!result) return null; + const ov = result.solve_overlay as SolveOverlay | undefined; + if (ov?.stars_matched?.length) return ov.stars_matched.length; + if (ov?.stars_all_centroids?.length) return ov.stars_all_centroids.length; + if (typeof result.matches === "number") return result.matches; + return null; +} + +const HISTORY_PAGE_SIZE = 30; +/** 调试控制台素材列表每页条数 / Items per page for debug media list */ +const DEBUG_PAGE_SIZE = 6; + +function isImageAsset(name: string): boolean { + return /\.(jpe?g|png|webp|bmp|gif|fits?)$/i.test(name); +} +function isVideoAsset(name: string): boolean { + return /\.(mp4|mov|webm|mkv|avi)$/i.test(name); +} + +function compactFilename(name: string, head = 16, tail = 14): string { + if (name.length <= head + tail + 1) return name; + return `${name.slice(0, head)}...${name.slice(-tail)}`; +} + +export function AnalysisLabPage() { + const { t, locale, setLocale } = useI18n(); + const [view, setView] = useState("lab_image"); + const [uploads, setUploads] = useState([]); + const [selected, setSelected] = useState(null); + const [params, setParams] = useState(defaultParams); + const [official, setOfficial] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [userPresets, setUserPresets] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [selectedPresetIds, setSelectedPresetIds] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [lastResult, setLastResult] = useState | null>(null); + const [batchPack, setBatchPack] = useState<{ + results: Array>; + } | null>(null); + const [historyQ, setHistoryQ] = useState(""); + const [historyPage, setHistoryPage] = useState(1); + const [historyData, setHistoryData] = useState<{ items: unknown[]; total: number } | null>( + null, + ); + const [historyExpandId, setHistoryExpandId] = useState(null); + const [newPresetName, setNewPresetName] = useState(""); + const [layers, setLayers] = useState({ matched: true, pattern: true, all: true }); + const [debugFiles, setDebugFiles] = useState([]); + const [debugPick, setDebugPick] = useState(null); + const [debugPage, setDebugPage] = useState(1); + const [gridOn, setGridOn] = useState(false); + const [zoom, setZoom] = useState(1); + const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }); + /** 视频文件素材的像素尺寸 / Video file intrinsic size */ + const [videoNatural, setVideoNatural] = useState({ w: 0, h: 0 }); + /** 相机预览 JPEG 尺寸 / Live camera preview image size */ + const [cameraPreviewNatural, setCameraPreviewNatural] = useState({ w: 0, h: 0 }); + /** 视频台:文件预览或设备相机 / Video lab: pool file vs device camera */ + const [videoPreviewMode, setVideoPreviewMode] = useState<"file" | "camera">("file"); + /** MJPEG 流时间戳参数,与相机调试台 /api/debug/camera/stream 一致 / Same stream URL as debug console */ + const [cameraStreamNonce, setCameraStreamNonce] = useState(() => Date.now()); + const [videoPreviewError, setVideoPreviewError] = useState(null); + const [cameraSolveRunning, setCameraSolveRunning] = useState(false); + const [fileSolveRunning, setFileSolveRunning] = useState(false); + const [autoHoldEnabled, setAutoHoldEnabled] = useState(false); + const [isFrozen, setIsFrozen] = useState(false); + const [frozenFrameId, setFrozenFrameId] = useState(null); + const [frozenImageUrl, setFrozenImageUrl] = useState(null); + const [meta, setMeta] = useState | null>(null); + const [metaLoading, setMetaLoading] = useState(false); + const [batchRawOpen, setBatchRawOpen] = useState>({}); + const [singleFooterRawOpen, setSingleFooterRawOpen] = useState(false); + /** 最近一次请求全链路耗时(含网络与渲染)/ Last request round-trip (network + render) */ + const [lastRoundTripMs, setLastRoundTripMs] = useState(null); + /** 最近一次解算输入来源 / Last solve input source */ + const [lastSolveSource, setLastSolveSource] = useState<"file" | "camera" | null>(null); + /** 实时解算调度提示 / Realtime solve gate hint */ + const [lastGateHint, setLastGateHint] = useState(null); + /** 用户期望解算间隔(毫秒)/ User desired realtime solve interval in ms */ + const [desiredSolveIntervalMs, setDesiredSolveIntervalMs] = useState(null); + const [transcodeBusy, setTranscodeBusy] = useState(false); + const [transcodeProgress, setTranscodeProgress] = useState(null); + const [transcodeHint, setTranscodeHint] = useState(null); + const [debugImportBusy, setDebugImportBusy] = useState(false); + const [debugImportProgress, setDebugImportProgress] = useState(null); + const [debugImportStep, setDebugImportStep] = useState(null); + const [debugImportMessage, setDebugImportMessage] = useState(null); + + const imgRef = useRef(null); + const videoRef = useRef(null); + const cameraPreviewImgRef = useRef(null); + const cameraSolveTimerRef = useRef(null); + const fileSolveTimerRef = useRef(null); + const cameraSolveInFlightRef = useRef(false); + const fileSolveInFlightRef = useRef(false); + const cvRef = useRef(null); + + /** 从当前预览 img 截一帧为 JPEG blob URL(用于冻结与星点同帧)/ Snapshot current preview frame for freeze */ + const captureCameraFrameAsBlobUrl = useCallback(async (): Promise => { + const el = cameraPreviewImgRef.current; + if (!el || el.naturalWidth < 2) return null; + const canvas = document.createElement("canvas"); + canvas.width = el.naturalWidth; + canvas.height = el.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + try { + ctx.drawImage(el, 0, 0); + } catch { + return null; + } + return await new Promise((resolve) => { + canvas.toBlob( + (b) => resolve(b ? URL.createObjectURL(b) : null), + "image/jpeg", + 0.92, + ); + }); + }, []); + const [sysOverview, setSysOverview] = useState(null); + const [labSettings, setLabSettings] = useState(null); + + const intervalMinMs = labSettings?.star_analysis_min_interval_ms ?? 2000; + const intervalMaxMs = labSettings?.star_analysis_max_interval_ms ?? 12000; + const defaultIntervalMs = useMemo(() => { + const fps = labSettings?.star_analysis_target_fps ?? 2 / 3; + const clampedFps = Math.min(Math.max(fps, 0.2), 5.0); + return Math.round(1000 / clampedFps); + }, [labSettings]); + const starAnalysisIntervalMs = useMemo(() => { + const raw = desiredSolveIntervalMs ?? defaultIntervalMs; + return Math.min(Math.max(raw, intervalMinMs), intervalMaxMs); + }, [defaultIntervalMs, desiredSolveIntervalMs, intervalMaxMs, intervalMinMs]); + + const loadLists = useCallback(async () => { + const [u, o, usr] = await Promise.all([ + fetchUploads(), + fetchPresets("official"), + fetchPresets("user"), + ]); + setUploads(u.files); + setOfficial(o.presets as typeof official); + setUserPresets(usr.presets as typeof userPresets); + }, []); + + const loadDebugFiles = useCallback(() => { + fetchDebugFiles() + .then((r) => setDebugFiles(r.files)) + .catch(() => setDebugFiles([])); + }, []); + + useEffect(() => { + fetchLabSettings() + .then((s) => { + setLabSettings(s); + const initialMs = Math.round(1000 / Math.min(Math.max(s.star_analysis_target_fps, 0.2), 5.0)); + const bounded = Math.min( + Math.max(initialMs, s.star_analysis_min_interval_ms), + s.star_analysis_max_interval_ms, + ); + setDesiredSolveIntervalMs(bounded); + }) + .catch(() => setLabSettings(null)); + }, []); + + useEffect(() => { + loadLists().catch((e) => setErr(String(e))); + }, [loadLists]); + + useEffect(() => { + loadDebugFiles(); + }, [loadDebugFiles]); + + useEffect(() => { + if (view !== "history") return; + fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE) + .then(setHistoryData) + .catch((e) => setErr(String(e))); + }, [view, historyQ, historyPage]); + + useEffect(() => { + if (!selected) { + setMeta(null); + return; + } + let cancelled = false; + setMetaLoading(true); + (async () => { + try { + const u = await fetchUploadFileInfo(selected); + if (cancelled) return; + let merged: Record = { ...u }; + try { + const d = await fetchDebugFileInfo(selected); + merged = { ...d, ...u }; + } catch { + /* 仅上传素材时调试目录无同名文件 / No debug file */ + } + setMeta(merged); + } catch { + try { + const d = await fetchDebugFileInfo(selected); + if (!cancelled) setMeta(d); + } catch { + if (!cancelled) setMeta(null); + } + } finally { + if (!cancelled) setMetaLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selected]); + + /** 切换素材时清空上一张的解算结果,避免误读 / Clear solve state when switching assets */ + useEffect(() => { + setLastResult(null); + setBatchPack(null); + setBatchRawOpen({}); + setSingleFooterRawOpen(false); + setLastRoundTripMs(null); + setLastSolveSource(null); + setVideoPreviewMode("file"); + setVideoPreviewError(null); + }, [selected]); + + const overlay = useMemo(() => { + const r = lastResult?.result as Record | undefined; + if (!r) return null; + const base = (r.solve_overlay || null) as SolveOverlay | null; + if (!base) return null; + const ext = (r.overlay_ext || null) as SolveOverlay["overlay_ext"] | null; + return { ...base, overlay_ext: ext || undefined }; + }, [lastResult]); + + const resultRow = useMemo(() => { + return (lastResult?.result as Record | undefined) ?? null; + }, [lastResult]); + + const starCount = useMemo(() => countStarsFromOverlay(resultRow), [resultRow]); + + /** 主预览区像素尺寸(图片 / 视频文件 / 相机 JPEG)/ Pixel size for metrics panel */ + const previewPixelDims = useMemo(() => { + if (view === "lab_image") return imgNatural; + if (view === "lab_video") { + return videoPreviewMode === "file" ? videoNatural : cameraPreviewNatural; + } + return { w: 0, h: 0 }; + }, [view, videoPreviewMode, imgNatural, videoNatural, cameraPreviewNatural]); + + const previewUrl = selected ? uploadFileUrl(selected) : ""; + + useEffect(() => { + if (view !== "lab_image") return; + const img = imgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, selected, lastResult, layers, view]); + + /** 视频文件:解算叠加 / Video file: solve overlay on current frame */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return; + const v = videoRef.current; + const cv = cvRef.current; + if (!v || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlayVideo(cv, v, overlay, layers); + v.addEventListener("loadeddata", draw); + v.addEventListener("seeked", draw); + if (v.readyState >= 2) draw(); + return () => { + v.removeEventListener("loadeddata", draw); + v.removeEventListener("seeked", draw); + }; + }, [overlay, layers, view, videoPreviewMode, selected, lastResult]); + + /** 设备相机预览:解算叠加在 JPEG 上 / Live camera JPEG + overlay */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + const img = cameraPreviewImgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, layers, view, videoPreviewMode, lastResult, cameraStreamNonce, isFrozen]); + + /** 进入设备相机模式时重连 MJPEG(与相机调试台同一路径)/ Reconnect MJPEG like debug console */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + setCameraStreamNonce(Date.now()); + }, [view, videoPreviewMode]); + + useEffect(() => { + const img = imgRef.current; + if (!img) return; + const upd = () => + setImgNatural({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 }); + upd(); + img.addEventListener("load", upd); + return () => img.removeEventListener("load", upd); + }, [selected, previewUrl]); + + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const upd = () => + setVideoNatural({ w: v.videoWidth || 0, h: v.videoHeight || 0 }); + v.addEventListener("loadedmetadata", upd); + v.addEventListener("loadeddata", upd); + upd(); + return () => { + v.removeEventListener("loadedmetadata", upd); + v.removeEventListener("loadeddata", upd); + }; + }, [selected, previewUrl, view]); + + const applyPreset = (p: SolveParams) => { + setParams({ + ...defaultParams(), + ...p, + centroid: { ...defaultParams().centroid, ...p.centroid }, + }); + }; + + const onSingleSolve = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const out = (await solveImage(selected, params)) as { result?: Record }; + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + const onBatch = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + const runs: BatchRun[] = []; + for (const id of selectedPresetIds) { + const all = [...official, ...userPresets]; + const pr = all.find((x) => x.id === id); + if (pr) + runs.push({ + label: pr.name, + params: structuredClone(pr.params) as SolveParams, + }); + } + if (runs.length === 0) { + setErr(t("err.selectPresets")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const pack = (await solveBatch(selected, runs)) as { results: unknown[] }; + setBatchPack({ + results: pack.results as Array>, + }); + setLastResult(null); + setBatchRawOpen({}); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + /** 设备相机当前帧解算(与素材池视频无关)/ Live camera frame solve */ + const onCameraSolve = async () => { + if (cameraSolveInFlightRef.current) return; + setErr(null); + cameraSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const out = await solveVideoFrame({ + source: "camera", + overlay_topn_count: 3, + enable_polar_guide: true, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + ...params, + }); + if (out.gate_status && out.gate_status !== "SOLVED") { + setLastGateHint(`${out.gate_status}${out.gate_reason ? `: ${out.gate_reason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("camera"); + setVideoPreviewMode("camera"); + const outResult = (out as { result?: Record }).result; + const solveStatus = + typeof outResult?.status === "string" ? String(outResult.status) : ""; + if (autoHoldEnabled && solveStatus === "MATCH_FOUND") { + setIsFrozen(true); + setFrozenFrameId( + (out as { frame_id?: number }).frame_id != null + ? String((out as { frame_id?: number }).frame_id) + : null, + ); + const snap = await captureCameraFrameAsBlobUrl(); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return snap; + }); + stopCameraSolveLoop(); + } + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + cameraSolveInFlightRef.current = false; + } + }; + + const onVideoFileSolve = async () => { + if (fileSolveInFlightRef.current) return; + if (!selected) return; + const vd = videoRef.current; + if (!vd || vd.videoWidth < 2 || vd.videoHeight < 2 || videoPreviewError) return; + setErr(null); + fileSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const canvas = document.createElement("canvas"); + canvas.width = vd.videoWidth; + canvas.height = vd.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("无法创建画布上下文 / Cannot create canvas context"); + } + ctx.drawImage(vd, 0, 0, canvas.width, canvas.height); + const frameBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error("帧编码失败 / Frame encode failed"))), + "image/jpeg", + 0.92, + ); + }); + const out = await solveFrameFromBlob(frameBlob, { + ...params, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + overlay_topn_count: 3, + enable_polar_guide: true, + }); + const gateStatus = (out as { gate_status?: string | null }).gate_status; + const gateReason = (out as { gate_reason?: string | null }).gate_reason; + if (gateStatus && gateStatus !== "SOLVED") { + setLastGateHint(`${gateStatus}${gateReason ? `: ${gateReason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + fileSolveInFlightRef.current = false; + } + }; + + const startFileSolveLoop = () => { + if (fileSolveRunning || !selected) return; + setFileSolveRunning(true); + void onVideoFileSolve(); + fileSolveTimerRef.current = window.setInterval(() => { + void onVideoFileSolve(); + }, starAnalysisIntervalMs); + }; + + const canSolveVideoFile = useMemo(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return false; + if (!selected) return false; + if (/\.avi$/i.test(selected)) return false; + if (videoPreviewError) return false; + return videoNatural.w > 1 && videoNatural.h > 1; + }, [view, videoPreviewMode, selected, videoPreviewError, videoNatural.w, videoNatural.h]); + + const isSelectedAvi = useMemo(() => !!selected && /\.avi$/i.test(selected), [selected]); + + const onTranscodeAndUploadAvi = async () => { + if (!selected || !isSelectedAvi || transcodeBusy) return; + setErr(null); + setTranscodeBusy(true); + setTranscodeProgress(0); + setTranscodeHint(t("lab.transcode.loading")); + try { + const srcUrl = uploadFileUrl(selected); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], selected, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + setTranscodeProgress(Math.max(0, Math.min(1, ratio))); + if (msg === "loading_ffmpeg") setTranscodeHint(t("lab.transcode.loading")); + else if (msg === "transcoding") setTranscodeHint(t("lab.transcode.running")); + else if (msg === "packing_output") setTranscodeHint(t("lab.transcode.packaging")); + }); + setTranscodeHint(t("lab.transcode.uploading")); + const upload = await uploadFile(out.file, "analysis_transcoded"); + await replaceTranscodedVideo({ + old_filename: selected, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + await loadLists(); + setSelected(upload.filename); + setTranscodeProgress(1); + setTranscodeHint(t("lab.transcode.done")); + } catch (e) { + setErr(String(e)); + setTranscodeHint(t("lab.transcode.failed")); + } finally { + setTranscodeBusy(false); + } + }; + + const transcodePoolAviAndReplace = async (aviFilename: string) => { + const srcUrl = uploadFileUrl(aviFilename); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], aviFilename, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + const base = 0.2; + const span = 0.6; + setDebugImportProgress(base + Math.max(0, Math.min(1, ratio)) * span); + if (msg === "loading_ffmpeg") setDebugImportStep(t("sidebar.flowLoadingTranscoder")); + else if (msg === "transcoding") setDebugImportStep(t("sidebar.flowTranscoding")); + else if (msg === "packing_output") setDebugImportStep(t("sidebar.flowPackaging")); + }); + setDebugImportStep(t("sidebar.flowUploadingMp4")); + setDebugImportProgress(0.86); + const upload = await uploadFile(out.file, "debug_console_transcoded"); + setDebugImportStep(t("sidebar.flowReplacing")); + setDebugImportProgress(0.94); + await replaceTranscodedVideo({ + old_filename: aviFilename, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + return upload.filename; + }; + + const runDebugImportFlow = async () => { + if (!debugPick || debugImportBusy) return; + const isAvi = /\.avi$/i.test(debugPick); + setErr(null); + setDebugImportBusy(true); + setDebugImportProgress(0.02); + setDebugImportMessage(null); + try { + setDebugImportStep(t("sidebar.flowImportingDebug")); + const imported = await importFromDebug(debugPick); + setDebugImportProgress(isAvi ? 0.18 : 1); + let target = imported.filename; + if (isAvi) { + target = await transcodePoolAviAndReplace(imported.filename); + } else { + setDebugImportStep(t("sidebar.flowNoTranscode")); + } + await loadLists(); + setSelected(target); + setView(isVideoAsset(target) ? "lab_video" : "lab_image"); + setDebugImportProgress(1); + setDebugImportStep(t("sidebar.flowDone")); + setDebugImportMessage(t("sidebar.flowDoneMsg", { name: compactFilename(target) })); + } catch (e) { + setErr(String(e)); + setDebugImportStep(t("sidebar.flowFailed")); + setDebugImportMessage(String(e)); + } finally { + setDebugImportBusy(false); + } + }; + + const stopFileSolveLoop = () => { + setFileSolveRunning(false); + if (fileSolveTimerRef.current != null) { + window.clearInterval(fileSolveTimerRef.current); + fileSolveTimerRef.current = null; + } + }; + + const startCameraSolveLoop = () => { + if (cameraSolveRunning) return; + if (isFrozen) return; + setCameraSolveRunning(true); + void onCameraSolve(); + cameraSolveTimerRef.current = window.setInterval(() => { + void onCameraSolve(); + }, starAnalysisIntervalMs); + }; + + const stopCameraSolveLoop = () => { + setCameraSolveRunning(false); + if (cameraSolveTimerRef.current != null) { + window.clearInterval(cameraSolveTimerRef.current); + cameraSolveTimerRef.current = null; + } + }; + + const stopCameraPreview = async () => { + try { + await fetch("/api/debug/camera/stop", { method: "POST" }); + setCameraStreamNonce(Date.now()); + if (videoPreviewMode === "camera") { + setVideoPreviewMode("file"); + } + } catch { + // no-op + } + }; + + const resumeLivePreview = () => { + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCameraStreamNonce(Date.now()); + }; + + const togglePreset = (id: string) => { + setSelectedPresetIds((prev) => { + const n = new Set(prev); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + }; + + const tryDeleteUpload = async (filename: string) => { + if (!window.confirm(t("delete.uploadFirst", { name: filename }))) return; + let nexp = 0; + try { + const c = await fetchUploadExperimentCount(filename); + nexp = c.count; + } catch { + nexp = 0; + } + if (nexp > 0) { + const ok = window.confirm(t("delete.uploadCascade", { n: nexp })); + if (!ok) return; + } else if (!window.confirm(t("delete.uploadSecond"))) { + return; + } + setBusy(true); + setErr(null); + try { + await deletePoolUpload(filename, { deleteExperiments: nexp > 0 }); + await loadLists(); + if (selected === filename) setSelected(null); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const tryDeleteExperiment = async (id: string) => { + if (!window.confirm(t("delete.experimentFirst"))) return; + if (!window.confirm(t("delete.experimentSecond"))) return; + setBusy(true); + setErr(null); + try { + await deleteExperimentRecord(id); + if (historyExpandId === id) setHistoryExpandId(null); + const data = await fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE); + setHistoryData(data); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const setZoomClamped = (z: number) => setZoom(Math.min(4, Math.max(0.5, z))); + + const historyTotalPages = Math.max( + 1, + Math.ceil((historyData?.total ?? 0) / HISTORY_PAGE_SIZE), + ); + + /** 按当前页面只列出对应类型调试素材 / Filter debug captures by lab view */ + const debugFilesForView = useMemo(() => { + if (view === "lab_image") { + return debugFiles.filter((f) => f.type === "image" || isImageAsset(f.name)); + } + if (view === "lab_video") { + return debugFiles.filter((f) => f.type === "video" || isVideoAsset(f.name)); + } + return debugFiles; + }, [debugFiles, view]); + + const debugTotalPages = Math.max( + 1, + Math.ceil(debugFilesForView.length / DEBUG_PAGE_SIZE), + ); + const debugPagedFiles = useMemo(() => { + const start = (debugPage - 1) * DEBUG_PAGE_SIZE; + return debugFilesForView.slice(start, start + DEBUG_PAGE_SIZE); + }, [debugFilesForView, debugPage]); + + useEffect(() => { + setDebugPage(1); + }, [view]); + + useEffect(() => { + setDebugPage((p) => Math.min(p, debugTotalPages)); + }, [debugTotalPages]); + + useEffect(() => { + if (debugPick && !debugFilesForView.some((f) => f.name === debugPick)) { + setDebugPick(null); + } + }, [debugFilesForView, debugPick]); + + const metaCaptionRows = useMemo( + () => buildMetaCaptionRows(meta, locale), + [meta, locale], + ); + + const sidebarUploads = useMemo(() => { + if (view === "lab_image") return uploads.filter((u) => isImageAsset(u.filename)); + if (view === "lab_video") return uploads.filter((u) => isVideoAsset(u.filename)); + return uploads; + }, [uploads, view]); + + const solveHud = useMemo(() => parseSolveResult(resultRow), [resultRow]); + + useEffect(() => { + setSingleFooterRawOpen(false); + }, [lastResult]); + useEffect(() => { + if (view !== "lab_video") return; + let id: ReturnType | undefined; + const tick = () => { + fetchSystemInfo().then(setSysOverview).catch(() => {}); + }; + tick(); + id = setInterval(tick, 1500); + return () => { + if (id) clearInterval(id); + }; + }, [view]); + + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") { + stopCameraSolveLoop(); + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + } + if (view !== "lab_video" || videoPreviewMode !== "file") { + stopFileSolveLoop(); + } + }, [view, videoPreviewMode]); + + useEffect(() => { + if (!selected) { + stopFileSolveLoop(); + } + }, [selected]); + + useEffect(() => { + return () => { + stopCameraSolveLoop(); + stopFileSolveLoop(); + }; + }, []); + + + return ( +
+
+
+ + {t("app.title")} + + +
+
+
+ + +
+ + {t("nav.systemAdmin")} + +
+
+ +
+ + + {(view === "lab_image" || view === "lab_video") && ( +
+
+ {err && ( +
+ {err} +
+ )} +
+ {lastGateHint && ( +
+ {lastGateHint} +
+ )} +
+ {view === "lab_video" && ( +
+

{t("lab.videoLiveIntro")}

+
+ + + +
+
+ )} +
+ {view === "lab_video" && videoPreviewMode === "camera" ? ( +
+
+ {!isFrozen || frozenImageUrl ? ( + + setCameraPreviewNatural({ + w: e.currentTarget.naturalWidth, + h: e.currentTarget.naturalHeight, + }) + } + /> + ) : ( +
+ + {t("lab.cameraPreviewLoading")} +
+ )} + +
+
+ ) : selected ? ( +
+ {view === "lab_video" ? ( + <> +
+ + + + +
+
+
+
+ {gridOn && ( +
+ )} +
+
+ {videoPreviewError && ( +
+ {videoPreviewError} +
+ )} + {isSelectedAvi && ( +
+
{t("lab.transcode.title")}
+
{t("lab.transcode.desc")}
+ {transcodeProgress != null && ( +
+
+ {transcodeHint || t("lab.transcode.running")} +
+
+
+
+
+ )} +
+ +
+
+ )} +
+ + ) : ( + <> +
+ + + + +
+
+
+ {gridOn && ( +
+ )} + preview + +
+
+ + )} +
+ ) : ( +
+ {view === "lab_video" ? t("lab.selectOrUploadVideo") : t("lab.selectOrUpload")} +
+ )} + {busy && ( +
+ +
+ )} +
+ {((selected && view === "lab_image") || + (view === "lab_video" && + ((videoPreviewMode === "file" && selected) || videoPreviewMode === "camera"))) && ( +
+ + {t("lab.layers")} + +
+ {(["matched", "pattern", "all"] as const).map((k) => ( + + ))} +
+
+ )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && ( + <> +
+
+ + {t("lab.solveSection")} + + +
+ {resultRow ? ( +
+ {solveHud.tSolveMs != null && ( +
+
+ + {t("lab.metric.solveComputeMs")} + + + {solveHud.tSolveMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveComputeHelp")} +

+
+ )} + {lastRoundTripMs != null && ( +
+
+ + {t("lab.metric.solveRoundTripMs")} + + + {lastRoundTripMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveRoundTripHelp")} +

+
+ )} + {(solveHud.tBackendTotalMs != null || + solveHud.tOpenDecodeMs != null || + solveHud.tPreprocessMs != null || + solveHud.tExtractMs != null || + solveHud.tSolveMs != null) && ( +
+
+ + {t("lab.metric.backendTotalMs")} + + + {solveHud.tBackendTotalMs != null + ? `${solveHud.tBackendTotalMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ + {t("lab.metric.openDecodeMs")}:{" "} + {solveHud.tOpenDecodeMs != null + ? `${solveHud.tOpenDecodeMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.preprocessMs")}:{" "} + {solveHud.tPreprocessMs != null + ? `${solveHud.tPreprocessMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.extractMs")}:{" "} + {solveHud.tExtractMs != null + ? `${solveHud.tExtractMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.solveOnlyMs")}:{" "} + {solveHud.tSolveMs != null + ? `${solveHud.tSolveMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ )} + {(solveHud.raDeg != null || solveHud.decDeg != null) && ( +
+ + {t("lab.metric.radec")} + +
+ α {formatAngleDeg(solveHud.raDeg)} · δ{" "} + {formatAngleDeg(solveHud.decDeg)} +
+
+ )} +
+ {solveHud.matches != null && ( + + + {t("lab.metric.matches")} + {" "} + {solveHud.matches} + + )} + {solveHud.rmseArcsec != null && ( + + + {t("lab.metric.rmse")} + {" "} + + {solveHud.rmseArcsec.toFixed(2)}″ + + + )} + {solveHud.prob != null && ( + + + + {t("lab.metric.prob")} + {" "} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).line} + + + + {t("lab.metric.probHelp")} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).rawLine && ( + <> + + {t("lab.metric.probRaw")}:{" "} + { + formatProbLine(solveHud.prob, resultRow ?? undefined) + .rawLine + } + + + {t("lab.metric.probRawHelp")} + + + )} + + )} +
+ {solveHud.status && ( +
+ + {t("lab.metric.status")}{" "} + + {solveHud.status} +
+ )} +
+ ) : ( +

{t("common.placeholder")}

+ )} +
+
+
+ + {t("lab.imageSection")} + + +
+
+ {t("lab.resolution")} + + {previewPixelDims.w > 0 + ? `${previewPixelDims.w}×${previewPixelDims.h}` + : t("common.placeholder")} + +
+
+ {t("lab.starsDetected")} + + {starCount != null ? starCount : t("common.placeholder")} + +
+
+ {t("lab.fwhm")} + {t("common.placeholder")} +
+
+
+
+ {selected && ( + <> +
+ + {t("lab.file")}:{" "} + + {compactFilename(selected, 22, 18)} + + + {uploads.find((u) => u.filename === selected)?.source && ( + + {t("lab.source")}:{" "} + {uploads.find((u) => u.filename === selected)?.source} + + )} +
+
+ + {t("lab.meta.title")} + {metaLoading && } + + +
+ {metaCaptionRows.length > 0 ? ( +
+ {metaCaptionRows.map((row) => ( +
+
{t(row.key)}
+
+ {row.value} +
+
+ ))} +
+ ) : meta && !metaLoading ? ( +

+ {t("lab.meta.partial")} +

+ ) : !metaLoading ? ( +

+ {t("lab.meta.noSidecar")} +

+ ) : null} +
+
+ + )} + + )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && + (lastResult || batchPack) && ( +
+
+ {t("results.title")} +
+ {batchPack?.results?.length ? ( + + ) : null} + {lastResult && ( + + )} +
+
+
+
+ {batchPack?.results.map((r, i) => { + const row = r.result as Record | undefined; + const rawOpen = batchRawOpen[i] ?? false; + return ( +
+
+ {String(r.label)} +
+ {r.success ? ( + <> + + + {rawOpen && ( +
+                                    {JSON.stringify(r, null, 2)}
+                                  
+ )} + + ) : ( +
{String(r.error)}
+ )} + {r.success === true && selected ? ( + + ) : null} +
+ ); + })} + {lastResult && !batchPack && ( +
+
+ {t("results.title")} +
+ | undefined} + t={t} + roundTripMs={lastRoundTripMs} + /> + + {singleFooterRawOpen && ( +
+                              {JSON.stringify(lastResult, null, 2)}
+                            
+ )} +
+ )} +
+
+
+ )} +
+ + +
+ )} + + {view === "pool" && ( +
+

+ {t("pool.title")} +

+ + + + + + + + + + + + {uploads.map((u) => ( + + + + + + + + ))} + +
{t("pool.col.name")}{t("pool.col.source")}{t("pool.col.size")}{t("pool.col.time")}{t("pool.delete")}
{u.filename}{u.source ?? t("common.placeholder")}{formatFileSize(u.size)}{formatDateTime(u.modified_at, locale)} + +
+
+ )} + + {view === "history" && ( +
+

+ {t("history.intro")} +

+
+

+ {t("history.title")} +

+ setHistoryQ(e.target.value)} + /> + + + +
+
+ {t("history.total", { n: historyData?.total ?? 0 })} + + + {historyPage} / {historyTotalPages} + + +
+
    + {(historyData?.items as Array>)?.map((row) => { + const id = String(row.id ?? ""); + const metrics = row.metrics as Record | undefined; + const open = historyExpandId === id; + return ( +
  • +
    +
    +
    + {formatDateTime(String(row.created_at ?? ""), locale)} —{" "} + {String(row.input_name)} — {String(row.preset_label)} +
    +
    + {t("history.preset")}: {String(row.preset_label)} · {t("history.metrics")}:{" "} + matches={String(metrics?.matches ?? "—")} rmse= + {String(metrics?.rmse_arcsec ?? "—")} +
    +
    +
    + + +
    +
    + {open && ( +
    + {row.asset_snapshot_relpath ? ( + + ) : null} +
    +                          {JSON.stringify(row.result_json, null, 2)}
    +                        
    +
    + )} +
  • + ); + })} +
+
+ )} +
+
+ ); +} + +function SolveFooterSummary({ + result, + t, + roundTripMs, +}: { + result: Record | null | undefined; + t: (key: string, vars?: Record) => string; + roundTripMs: number | null; +}) { + const s = parseSolveResult(result ?? undefined); + if (!result) { + return

; + } + return ( +
+
+ {s.tBackendTotalMs != null && ( +
+
{t("lab.metric.backendTotalMs")}
+
+ {s.tBackendTotalMs.toFixed(0)} ms +
+
+ )} + {s.tSolveMs != null && ( +
+
{t("lab.metric.solveComputeMs")}
+
+ {s.tSolveMs.toFixed(0)} ms +
+
+ )} + {s.tOpenDecodeMs != null && ( +
+
{t("lab.metric.openDecodeMs")}
+
+ {s.tOpenDecodeMs.toFixed(0)} ms +
+
+ )} + {s.tPreprocessMs != null && ( +
+
{t("lab.metric.preprocessMs")}
+
+ {s.tPreprocessMs.toFixed(0)} ms +
+
+ )} + {s.tExtractMs != null && ( +
+
{t("lab.metric.extractMs")}
+
+ {s.tExtractMs.toFixed(0)} ms +
+
+ )} + {roundTripMs != null && ( +
+
{t("lab.metric.solveRoundTripMs")}
+
+ {roundTripMs.toFixed(0)} ms +
+
+ )} + {s.matches != null && ( +
+
{t("lab.metric.matches")}
+
{s.matches}
+
+ )} + {s.rmseArcsec != null && ( +
+
{t("lab.metric.rmse")}
+
+ {s.rmseArcsec.toFixed(2)}″ +
+
+ )} + {s.prob != null && ( +
+
{t("lab.metric.prob")}
+
+ {formatProbLine(s.prob, result).line} +
+

+ {t("lab.metric.probHelp")} +

+ {formatProbLine(s.prob, result).rawLine && ( +
+ {t("lab.metric.probRaw")}: {formatProbLine(s.prob, result).rawLine} +

+ {t("lab.metric.probRawHelp")} +

+
+ )} +
+ )} +
+
+
{t("lab.metric.radec")}
+
+ α {formatAngleDeg(s.raDeg)} · δ {formatAngleDeg(s.decDeg)} +
+
+ {s.status && ( +
+ {t("lab.metric.status")}: {s.status} +
+ )} +
+ ); +} + +function Field({ + label, + helpKey, + value, + onChange, + type = "number", + step, +}: { + label: string; + helpKey?: string; + value: number | ""; + onChange: (v: number | undefined) => void; + type?: string; + step?: number; +}) { + const { t } = useI18n(); + const help = helpKey ? t(helpKey) : undefined; + return ( + + ); +} diff --git a/web/analysis-ui/src/App.tsx b/web/analysis-ui/src/App.tsx new file mode 100644 index 0000000..9957111 --- /dev/null +++ b/web/analysis-ui/src/App.tsx @@ -0,0 +1,2577 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Database, + Grid3x3, + History, + Home, + Loader2, + RefreshCw, + RotateCcw, + Trash2, + Upload, + ZoomIn, + ZoomOut, + ChevronDown, +} from "lucide-react"; +import { + BatchRun, + debugCaptureFileUrl, + deleteExperimentRecord, + deletePoolUpload, + experimentAssetUrl, + exportExperiments, + fetchDebugFileInfo, + fetchDebugFiles, + fetchExperiments, + fetchLabSettings, + fetchPresets, + fetchSystemInfo, + fetchUploadExperimentCount, + fetchUploadFileInfo, + fetchUploads, + importFromDebug, + replaceTranscodedVideo, + saveExperiment, + saveUserPreset, + solveBatch, + solveFrameFromBlob, + solveImage, + solveVideoFrame, + uploadFile, + uploadFileUrl, + type DebugFileRow, + type LabPublicSettings, + type SolveParams, + type UploadFileRow, +} from "./api"; +import { + drawSolveOverlay, + drawSolveOverlayVideo, + type SolveOverlay, +} from "./drawOverlay"; +import { transcodeAviToMp4 } from "./utils/transcode"; +import { useI18n } from "./i18n/I18nProvider"; +import { buildMetaCaptionRows } from "./utils/metaCaption"; +import { formatDateTime, formatFileSize } from "./utils/format"; +import { + formatAngleDeg, + formatProbLine, + parseSolveResult, +} from "./utils/solveDisplay"; + +type View = "lab_image" | "lab_video" | "pool" | "history"; + +const defaultParams = (): SolveParams => ({ + hint_ra_deg: 45, + hint_dec_deg: 80, + fov_estimate: 11, + fov_max_error: undefined, + solve_timeout_ms: 1500, + solve_profile: "balanced", + max_image_side: 1600, + large_scale_bg_subtract: false, + detail_level: "summary", + centroid: { + sigma: 2.5, + max_area: 400, + min_area: 5, + filtsize: 25, + binary_open: true, + max_axis_ratio: undefined, + }, +}); + +function metricsFromResult(result: Record | null): Record { + if (!result) return {}; + return { + matches: result.matches, + rmse_arcsec: result.rmse_arcsec, + status: result.status, + prob: result.prob, + t_solve_ms: result.t_solve_ms, + }; +} + +function countStarsFromOverlay( + result: Record | null, +): number | null { + if (!result) return null; + const ov = result.solve_overlay as SolveOverlay | undefined; + if (ov?.stars_matched?.length) return ov.stars_matched.length; + if (ov?.stars_all_centroids?.length) return ov.stars_all_centroids.length; + if (typeof result.matches === "number") return result.matches; + return null; +} + +const HISTORY_PAGE_SIZE = 30; +/** 调试控制台素材列表每页条数 / Items per page for debug media list */ +const DEBUG_PAGE_SIZE = 6; + +function isImageAsset(name: string): boolean { + return /\.(jpe?g|png|webp|bmp|gif|fits?)$/i.test(name); +} +function isVideoAsset(name: string): boolean { + return /\.(mp4|mov|webm|mkv|avi)$/i.test(name); +} + +function compactFilename(name: string, head = 16, tail = 14): string { + if (name.length <= head + tail + 1) return name; + return `${name.slice(0, head)}...${name.slice(-tail)}`; +} + +export default function App() { + const { t, locale, setLocale } = useI18n(); + const [view, setView] = useState("lab_image"); + const [uploads, setUploads] = useState([]); + const [selected, setSelected] = useState(null); + const [params, setParams] = useState(defaultParams); + const [official, setOfficial] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [userPresets, setUserPresets] = useState< + Array<{ id: string; name: string; params: SolveParams }> + >([]); + const [selectedPresetIds, setSelectedPresetIds] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const [lastResult, setLastResult] = useState | null>(null); + const [batchPack, setBatchPack] = useState<{ + results: Array>; + } | null>(null); + const [historyQ, setHistoryQ] = useState(""); + const [historyPage, setHistoryPage] = useState(1); + const [historyData, setHistoryData] = useState<{ items: unknown[]; total: number } | null>( + null, + ); + const [historyExpandId, setHistoryExpandId] = useState(null); + const [newPresetName, setNewPresetName] = useState(""); + const [layers, setLayers] = useState({ matched: true, pattern: true, all: true }); + const [debugFiles, setDebugFiles] = useState([]); + const [debugPick, setDebugPick] = useState(null); + const [debugPage, setDebugPage] = useState(1); + const [gridOn, setGridOn] = useState(false); + const [zoom, setZoom] = useState(1); + const [imgNatural, setImgNatural] = useState({ w: 0, h: 0 }); + /** 视频文件素材的像素尺寸 / Video file intrinsic size */ + const [videoNatural, setVideoNatural] = useState({ w: 0, h: 0 }); + /** 相机预览 JPEG 尺寸 / Live camera preview image size */ + const [cameraPreviewNatural, setCameraPreviewNatural] = useState({ w: 0, h: 0 }); + /** 视频台:文件预览或设备相机 / Video lab: pool file vs device camera */ + const [videoPreviewMode, setVideoPreviewMode] = useState<"file" | "camera">("file"); + /** MJPEG 流时间戳参数,与相机调试台 /api/debug/camera/stream 一致 / Same stream URL as debug console */ + const [cameraStreamNonce, setCameraStreamNonce] = useState(() => Date.now()); + const [videoPreviewError, setVideoPreviewError] = useState(null); + const [cameraSolveRunning, setCameraSolveRunning] = useState(false); + const [fileSolveRunning, setFileSolveRunning] = useState(false); + const [autoHoldEnabled, setAutoHoldEnabled] = useState(false); + const [isFrozen, setIsFrozen] = useState(false); + const [frozenFrameId, setFrozenFrameId] = useState(null); + const [frozenImageUrl, setFrozenImageUrl] = useState(null); + const [meta, setMeta] = useState | null>(null); + const [metaLoading, setMetaLoading] = useState(false); + const [batchRawOpen, setBatchRawOpen] = useState>({}); + const [singleFooterRawOpen, setSingleFooterRawOpen] = useState(false); + /** 最近一次请求全链路耗时(含网络与渲染)/ Last request round-trip (network + render) */ + const [lastRoundTripMs, setLastRoundTripMs] = useState(null); + /** 最近一次解算输入来源 / Last solve input source */ + const [lastSolveSource, setLastSolveSource] = useState<"file" | "camera" | null>(null); + /** 实时解算调度提示 / Realtime solve gate hint */ + const [lastGateHint, setLastGateHint] = useState(null); + /** 用户期望解算间隔(毫秒)/ User desired realtime solve interval in ms */ + const [desiredSolveIntervalMs, setDesiredSolveIntervalMs] = useState(null); + const [transcodeBusy, setTranscodeBusy] = useState(false); + const [transcodeProgress, setTranscodeProgress] = useState(null); + const [transcodeHint, setTranscodeHint] = useState(null); + const [debugImportBusy, setDebugImportBusy] = useState(false); + const [debugImportProgress, setDebugImportProgress] = useState(null); + const [debugImportStep, setDebugImportStep] = useState(null); + const [debugImportMessage, setDebugImportMessage] = useState(null); + + const imgRef = useRef(null); + const videoRef = useRef(null); + const cameraPreviewImgRef = useRef(null); + const cameraSolveTimerRef = useRef(null); + const fileSolveTimerRef = useRef(null); + const cameraSolveInFlightRef = useRef(false); + const fileSolveInFlightRef = useRef(false); + const cvRef = useRef(null); + + /** 从当前预览 img 截一帧为 JPEG blob URL(用于冻结与星点同帧)/ Snapshot current preview frame for freeze */ + const captureCameraFrameAsBlobUrl = useCallback(async (): Promise => { + const el = cameraPreviewImgRef.current; + if (!el || el.naturalWidth < 2) return null; + const canvas = document.createElement("canvas"); + canvas.width = el.naturalWidth; + canvas.height = el.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + try { + ctx.drawImage(el, 0, 0); + } catch { + return null; + } + return await new Promise((resolve) => { + canvas.toBlob( + (b) => resolve(b ? URL.createObjectURL(b) : null), + "image/jpeg", + 0.92, + ); + }); + }, []); + const [sysOverview, setSysOverview] = useState(null); + const [labSettings, setLabSettings] = useState(null); + + const intervalMinMs = labSettings?.star_analysis_min_interval_ms ?? 2000; + const intervalMaxMs = labSettings?.star_analysis_max_interval_ms ?? 12000; + const defaultIntervalMs = useMemo(() => { + const fps = labSettings?.star_analysis_target_fps ?? 2 / 3; + const clampedFps = Math.min(Math.max(fps, 0.2), 5.0); + return Math.round(1000 / clampedFps); + }, [labSettings]); + const starAnalysisIntervalMs = useMemo(() => { + const raw = desiredSolveIntervalMs ?? defaultIntervalMs; + return Math.min(Math.max(raw, intervalMinMs), intervalMaxMs); + }, [defaultIntervalMs, desiredSolveIntervalMs, intervalMaxMs, intervalMinMs]); + + const loadLists = useCallback(async () => { + const [u, o, usr] = await Promise.all([ + fetchUploads(), + fetchPresets("official"), + fetchPresets("user"), + ]); + setUploads(u.files); + setOfficial(o.presets as typeof official); + setUserPresets(usr.presets as typeof userPresets); + }, []); + + const loadDebugFiles = useCallback(() => { + fetchDebugFiles() + .then((r) => setDebugFiles(r.files)) + .catch(() => setDebugFiles([])); + }, []); + + useEffect(() => { + fetchLabSettings() + .then((s) => { + setLabSettings(s); + const initialMs = Math.round(1000 / Math.min(Math.max(s.star_analysis_target_fps, 0.2), 5.0)); + const bounded = Math.min( + Math.max(initialMs, s.star_analysis_min_interval_ms), + s.star_analysis_max_interval_ms, + ); + setDesiredSolveIntervalMs(bounded); + }) + .catch(() => setLabSettings(null)); + }, []); + + useEffect(() => { + loadLists().catch((e) => setErr(String(e))); + }, [loadLists]); + + useEffect(() => { + loadDebugFiles(); + }, [loadDebugFiles]); + + useEffect(() => { + if (view !== "history") return; + fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE) + .then(setHistoryData) + .catch((e) => setErr(String(e))); + }, [view, historyQ, historyPage]); + + useEffect(() => { + if (!selected) { + setMeta(null); + return; + } + let cancelled = false; + setMetaLoading(true); + (async () => { + try { + const u = await fetchUploadFileInfo(selected); + if (cancelled) return; + let merged: Record = { ...u }; + try { + const d = await fetchDebugFileInfo(selected); + merged = { ...d, ...u }; + } catch { + /* 仅上传素材时调试目录无同名文件 / No debug file */ + } + setMeta(merged); + } catch { + try { + const d = await fetchDebugFileInfo(selected); + if (!cancelled) setMeta(d); + } catch { + if (!cancelled) setMeta(null); + } + } finally { + if (!cancelled) setMetaLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, [selected]); + + /** 切换素材时清空上一张的解算结果,避免误读 / Clear solve state when switching assets */ + useEffect(() => { + setLastResult(null); + setBatchPack(null); + setBatchRawOpen({}); + setSingleFooterRawOpen(false); + setLastRoundTripMs(null); + setLastSolveSource(null); + setVideoPreviewMode("file"); + setVideoPreviewError(null); + }, [selected]); + + const overlay = useMemo(() => { + const r = lastResult?.result as Record | undefined; + if (!r) return null; + const base = (r.solve_overlay || null) as SolveOverlay | null; + if (!base) return null; + const ext = (r.overlay_ext || null) as SolveOverlay["overlay_ext"] | null; + return { ...base, overlay_ext: ext || undefined }; + }, [lastResult]); + + const resultRow = useMemo(() => { + return (lastResult?.result as Record | undefined) ?? null; + }, [lastResult]); + + const starCount = useMemo(() => countStarsFromOverlay(resultRow), [resultRow]); + + /** 主预览区像素尺寸(图片 / 视频文件 / 相机 JPEG)/ Pixel size for metrics panel */ + const previewPixelDims = useMemo(() => { + if (view === "lab_image") return imgNatural; + if (view === "lab_video") { + return videoPreviewMode === "file" ? videoNatural : cameraPreviewNatural; + } + return { w: 0, h: 0 }; + }, [view, videoPreviewMode, imgNatural, videoNatural, cameraPreviewNatural]); + + const previewUrl = selected ? uploadFileUrl(selected) : ""; + + useEffect(() => { + if (view !== "lab_image") return; + const img = imgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, selected, lastResult, layers, view]); + + /** 视频文件:解算叠加 / Video file: solve overlay on current frame */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return; + const v = videoRef.current; + const cv = cvRef.current; + if (!v || !cv || !overlay || !selected) return; + const draw = () => drawSolveOverlayVideo(cv, v, overlay, layers); + v.addEventListener("loadeddata", draw); + v.addEventListener("seeked", draw); + if (v.readyState >= 2) draw(); + return () => { + v.removeEventListener("loadeddata", draw); + v.removeEventListener("seeked", draw); + }; + }, [overlay, layers, view, videoPreviewMode, selected, lastResult]); + + /** 设备相机预览:解算叠加在 JPEG 上 / Live camera JPEG + overlay */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + const img = cameraPreviewImgRef.current; + const cv = cvRef.current; + if (!img || !cv || !overlay) return; + const draw = () => drawSolveOverlay(cv, img, overlay, layers); + if (img.complete) draw(); + else img.onload = draw; + }, [overlay, layers, view, videoPreviewMode, lastResult, cameraStreamNonce, isFrozen]); + + /** 进入设备相机模式时重连 MJPEG(与相机调试台同一路径)/ Reconnect MJPEG like debug console */ + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") return; + setCameraStreamNonce(Date.now()); + }, [view, videoPreviewMode]); + + useEffect(() => { + const img = imgRef.current; + if (!img) return; + const upd = () => + setImgNatural({ w: img.naturalWidth || 0, h: img.naturalHeight || 0 }); + upd(); + img.addEventListener("load", upd); + return () => img.removeEventListener("load", upd); + }, [selected, previewUrl]); + + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const upd = () => + setVideoNatural({ w: v.videoWidth || 0, h: v.videoHeight || 0 }); + v.addEventListener("loadedmetadata", upd); + v.addEventListener("loadeddata", upd); + upd(); + return () => { + v.removeEventListener("loadedmetadata", upd); + v.removeEventListener("loadeddata", upd); + }; + }, [selected, previewUrl, view]); + + const applyPreset = (p: SolveParams) => { + setParams({ + ...defaultParams(), + ...p, + centroid: { ...defaultParams().centroid, ...p.centroid }, + }); + }; + + const onSingleSolve = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const out = (await solveImage(selected, params)) as { result?: Record }; + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + const onBatch = async () => { + if (!selected) { + setErr(t("err.selectFile")); + return; + } + const runs: BatchRun[] = []; + for (const id of selectedPresetIds) { + const all = [...official, ...userPresets]; + const pr = all.find((x) => x.id === id); + if (pr) + runs.push({ + label: pr.name, + params: structuredClone(pr.params) as SolveParams, + }); + } + if (runs.length === 0) { + setErr(t("err.selectPresets")); + return; + } + setErr(null); + setBusy(true); + const t0 = performance.now(); + try { + const pack = (await solveBatch(selected, runs)) as { results: unknown[] }; + setBatchPack({ + results: pack.results as Array>, + }); + setLastResult(null); + setBatchRawOpen({}); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + setBusy(false); + } + }; + + /** 设备相机当前帧解算(与素材池视频无关)/ Live camera frame solve */ + const onCameraSolve = async () => { + if (cameraSolveInFlightRef.current) return; + setErr(null); + cameraSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const out = await solveVideoFrame({ + source: "camera", + overlay_topn_count: 3, + enable_polar_guide: true, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + ...params, + }); + if (out.gate_status && out.gate_status !== "SOLVED") { + setLastGateHint(`${out.gate_status}${out.gate_reason ? `: ${out.gate_reason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("camera"); + setVideoPreviewMode("camera"); + const outResult = (out as { result?: Record }).result; + const solveStatus = + typeof outResult?.status === "string" ? String(outResult.status) : ""; + if (autoHoldEnabled && solveStatus === "MATCH_FOUND") { + setIsFrozen(true); + setFrozenFrameId( + (out as { frame_id?: number }).frame_id != null + ? String((out as { frame_id?: number }).frame_id) + : null, + ); + const snap = await captureCameraFrameAsBlobUrl(); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return snap; + }); + stopCameraSolveLoop(); + } + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + cameraSolveInFlightRef.current = false; + } + }; + + const onVideoFileSolve = async () => { + if (fileSolveInFlightRef.current) return; + if (!selected) return; + const vd = videoRef.current; + if (!vd || vd.videoWidth < 2 || vd.videoHeight < 2 || videoPreviewError) return; + setErr(null); + fileSolveInFlightRef.current = true; + const t0 = performance.now(); + try { + const canvas = document.createElement("canvas"); + canvas.width = vd.videoWidth; + canvas.height = vd.videoHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("无法创建画布上下文 / Cannot create canvas context"); + } + ctx.drawImage(vd, 0, 0, canvas.width, canvas.height); + const frameBlob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error("帧编码失败 / Frame encode failed"))), + "image/jpeg", + 0.92, + ); + }); + const out = await solveFrameFromBlob(frameBlob, { + ...params, + solve_interval_ms: starAnalysisIntervalMs, + solve_timeout_ms: Math.min((labSettings?.solver_timeout_ms ?? 1500) * 0.6, 1200), + overlay_topn_count: 3, + enable_polar_guide: true, + }); + const gateStatus = (out as { gate_status?: string | null }).gate_status; + const gateReason = (out as { gate_reason?: string | null }).gate_reason; + if (gateStatus && gateStatus !== "SOLVED") { + setLastGateHint(`${gateStatus}${gateReason ? `: ${gateReason}` : ""}`); + } else { + setLastGateHint(null); + } + setLastResult(out as Record); + setBatchPack(null); + setLastSolveSource("file"); + setLastRoundTripMs(performance.now() - t0); + } catch (e) { + setErr(String(e)); + setLastRoundTripMs(null); + } finally { + fileSolveInFlightRef.current = false; + } + }; + + const startFileSolveLoop = () => { + if (fileSolveRunning || !selected) return; + setFileSolveRunning(true); + void onVideoFileSolve(); + fileSolveTimerRef.current = window.setInterval(() => { + void onVideoFileSolve(); + }, starAnalysisIntervalMs); + }; + + const canSolveVideoFile = useMemo(() => { + if (view !== "lab_video" || videoPreviewMode !== "file") return false; + if (!selected) return false; + if (/\.avi$/i.test(selected)) return false; + if (videoPreviewError) return false; + return videoNatural.w > 1 && videoNatural.h > 1; + }, [view, videoPreviewMode, selected, videoPreviewError, videoNatural.w, videoNatural.h]); + + const isSelectedAvi = useMemo(() => !!selected && /\.avi$/i.test(selected), [selected]); + + const onTranscodeAndUploadAvi = async () => { + if (!selected || !isSelectedAvi || transcodeBusy) return; + setErr(null); + setTranscodeBusy(true); + setTranscodeProgress(0); + setTranscodeHint(t("lab.transcode.loading")); + try { + const srcUrl = uploadFileUrl(selected); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], selected, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + setTranscodeProgress(Math.max(0, Math.min(1, ratio))); + if (msg === "loading_ffmpeg") setTranscodeHint(t("lab.transcode.loading")); + else if (msg === "writing_input") setTranscodeHint(t("lab.transcode.writingBuffer")); + else if (msg === "transcoding") setTranscodeHint(t("lab.transcode.running")); + else if (msg === "packing_output") setTranscodeHint(t("lab.transcode.packaging")); + }); + setTranscodeHint(t("lab.transcode.uploading")); + const upload = await uploadFile(out.file, "analysis_transcoded"); + await replaceTranscodedVideo({ + old_filename: selected, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + await loadLists(); + setSelected(upload.filename); + setTranscodeProgress(1); + setTranscodeHint(t("lab.transcode.done")); + } catch (e) { + setErr(String(e)); + setTranscodeHint(t("lab.transcode.failed")); + } finally { + setTranscodeBusy(false); + } + }; + + const transcodePoolAviAndReplace = async (aviFilename: string) => { + setDebugImportStep(t("sidebar.flowDownloadingPool")); + setDebugImportProgress(0.19); + const srcUrl = uploadFileUrl(aviFilename); + const res = await fetch(srcUrl, { cache: "no-store" }); + if (!res.ok) throw new Error(t("lab.transcode.fetchFailed")); + const blob = await res.blob(); + const aviFile = new File([blob], aviFilename, { type: "video/x-msvideo" }); + const out = await transcodeAviToMp4(aviFile, (ratio, msg) => { + const base = 0.2; + const span = 0.6; + setDebugImportProgress(base + Math.max(0, Math.min(1, ratio)) * span); + if (msg === "loading_ffmpeg") setDebugImportStep(t("sidebar.flowLoadingTranscoder")); + else if (msg === "writing_input") setDebugImportStep(t("sidebar.flowWritingBuffer")); + else if (msg === "transcoding") setDebugImportStep(t("sidebar.flowTranscoding")); + else if (msg === "packing_output") setDebugImportStep(t("sidebar.flowPackaging")); + }); + setDebugImportStep(t("sidebar.flowUploadingMp4")); + setDebugImportProgress(0.86); + const upload = await uploadFile(out.file, "debug_console_transcoded"); + setDebugImportStep(t("sidebar.flowReplacing")); + setDebugImportProgress(0.94); + await replaceTranscodedVideo({ + old_filename: aviFilename, + new_filename: upload.filename, + duration_s: out.duration_s, + nominal_fps: null, + codec_fourcc: "libx264", + container: "MP4", + }); + return upload.filename; + }; + + const runDebugImportFlow = async () => { + if (!debugPick || debugImportBusy) return; + const isAvi = /\.avi$/i.test(debugPick); + setErr(null); + setDebugImportBusy(true); + setDebugImportProgress(0.02); + setDebugImportMessage(null); + try { + setDebugImportStep(t("sidebar.flowImportingDebug")); + const imported = await importFromDebug(debugPick); + setDebugImportProgress(isAvi ? 0.18 : 1); + let target = imported.filename; + if (isAvi) { + target = await transcodePoolAviAndReplace(imported.filename); + } else { + setDebugImportStep(t("sidebar.flowNoTranscode")); + } + await loadLists(); + setSelected(target); + setView(isVideoAsset(target) ? "lab_video" : "lab_image"); + setDebugImportProgress(1); + setDebugImportStep(t("sidebar.flowDone")); + setDebugImportMessage(t("sidebar.flowDoneMsg", { name: compactFilename(target) })); + } catch (e) { + setErr(String(e)); + setDebugImportStep(t("sidebar.flowFailed")); + setDebugImportMessage(String(e)); + } finally { + setDebugImportBusy(false); + } + }; + + const stopFileSolveLoop = () => { + setFileSolveRunning(false); + if (fileSolveTimerRef.current != null) { + window.clearInterval(fileSolveTimerRef.current); + fileSolveTimerRef.current = null; + } + }; + + const startCameraSolveLoop = () => { + if (cameraSolveRunning) return; + if (isFrozen) return; + setCameraSolveRunning(true); + void onCameraSolve(); + cameraSolveTimerRef.current = window.setInterval(() => { + void onCameraSolve(); + }, starAnalysisIntervalMs); + }; + + const stopCameraSolveLoop = () => { + setCameraSolveRunning(false); + if (cameraSolveTimerRef.current != null) { + window.clearInterval(cameraSolveTimerRef.current); + cameraSolveTimerRef.current = null; + } + }; + + const stopCameraPreview = async () => { + try { + await fetch("/api/debug/camera/stop", { method: "POST" }); + setCameraStreamNonce(Date.now()); + if (videoPreviewMode === "camera") { + setVideoPreviewMode("file"); + } + } catch { + // no-op + } + }; + + const resumeLivePreview = () => { + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + setCameraStreamNonce(Date.now()); + }; + + const togglePreset = (id: string) => { + setSelectedPresetIds((prev) => { + const n = new Set(prev); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + }; + + const tryDeleteUpload = async (filename: string) => { + if (!window.confirm(t("delete.uploadFirst", { name: filename }))) return; + let nexp = 0; + try { + const c = await fetchUploadExperimentCount(filename); + nexp = c.count; + } catch { + nexp = 0; + } + if (nexp > 0) { + const ok = window.confirm(t("delete.uploadCascade", { n: nexp })); + if (!ok) return; + } else if (!window.confirm(t("delete.uploadSecond"))) { + return; + } + setBusy(true); + setErr(null); + try { + await deletePoolUpload(filename, { deleteExperiments: nexp > 0 }); + await loadLists(); + if (selected === filename) setSelected(null); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const tryDeleteExperiment = async (id: string) => { + if (!window.confirm(t("delete.experimentFirst"))) return; + if (!window.confirm(t("delete.experimentSecond"))) return; + setBusy(true); + setErr(null); + try { + await deleteExperimentRecord(id); + if (historyExpandId === id) setHistoryExpandId(null); + const data = await fetchExperiments(historyQ, historyPage, HISTORY_PAGE_SIZE); + setHistoryData(data); + } catch (e) { + setErr(String(e)); + } finally { + setBusy(false); + } + }; + + const setZoomClamped = (z: number) => setZoom(Math.min(4, Math.max(0.5, z))); + + const historyTotalPages = Math.max( + 1, + Math.ceil((historyData?.total ?? 0) / HISTORY_PAGE_SIZE), + ); + + /** 按当前页面只列出对应类型调试素材 / Filter debug captures by lab view */ + const debugFilesForView = useMemo(() => { + if (view === "lab_image") { + return debugFiles.filter((f) => f.type === "image" || isImageAsset(f.name)); + } + if (view === "lab_video") { + return debugFiles.filter((f) => f.type === "video" || isVideoAsset(f.name)); + } + return debugFiles; + }, [debugFiles, view]); + + const debugTotalPages = Math.max( + 1, + Math.ceil(debugFilesForView.length / DEBUG_PAGE_SIZE), + ); + const debugPagedFiles = useMemo(() => { + const start = (debugPage - 1) * DEBUG_PAGE_SIZE; + return debugFilesForView.slice(start, start + DEBUG_PAGE_SIZE); + }, [debugFilesForView, debugPage]); + + useEffect(() => { + setDebugPage(1); + }, [view]); + + useEffect(() => { + setDebugPage((p) => Math.min(p, debugTotalPages)); + }, [debugTotalPages]); + + useEffect(() => { + if (debugPick && !debugFilesForView.some((f) => f.name === debugPick)) { + setDebugPick(null); + } + }, [debugFilesForView, debugPick]); + + const metaCaptionRows = useMemo( + () => buildMetaCaptionRows(meta, locale), + [meta, locale], + ); + + const sidebarUploads = useMemo(() => { + if (view === "lab_image") return uploads.filter((u) => isImageAsset(u.filename)); + if (view === "lab_video") return uploads.filter((u) => isVideoAsset(u.filename)); + return uploads; + }, [uploads, view]); + + const solveHud = useMemo(() => parseSolveResult(resultRow), [resultRow]); + + useEffect(() => { + setSingleFooterRawOpen(false); + }, [lastResult]); + useEffect(() => { + if (view !== "lab_video") return; + let id: ReturnType | undefined; + const tick = () => { + fetchSystemInfo().then(setSysOverview).catch(() => {}); + }; + tick(); + id = setInterval(tick, 1500); + return () => { + if (id) clearInterval(id); + }; + }, [view]); + + useEffect(() => { + if (view !== "lab_video" || videoPreviewMode !== "camera") { + stopCameraSolveLoop(); + setIsFrozen(false); + setFrozenFrameId(null); + setFrozenImageUrl((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + } + if (view !== "lab_video" || videoPreviewMode !== "file") { + stopFileSolveLoop(); + } + }, [view, videoPreviewMode]); + + useEffect(() => { + if (!selected) { + stopFileSolveLoop(); + } + }, [selected]); + + useEffect(() => { + return () => { + stopCameraSolveLoop(); + stopFileSolveLoop(); + }; + }, []); + + + return ( +
+
+
+ + {t("app.title")} + + +
+
+
+ + +
+ + {t("nav.systemAdmin")} + +
+
+ +
+ + + {(view === "lab_image" || view === "lab_video") && ( +
+
+ {err && ( +
+ {err} +
+ )} +
+ {lastGateHint && ( +
+ {lastGateHint} +
+ )} +
+ {view === "lab_video" && ( +
+

{t("lab.videoLiveIntro")}

+
+ + + +
+
+ )} +
+ {view === "lab_video" && videoPreviewMode === "camera" ? ( +
+
+ {!isFrozen || frozenImageUrl ? ( + + setCameraPreviewNatural({ + w: e.currentTarget.naturalWidth, + h: e.currentTarget.naturalHeight, + }) + } + /> + ) : ( +
+ + {t("lab.cameraPreviewLoading")} +
+ )} + +
+
+ ) : selected ? ( +
+ {view === "lab_video" ? ( + <> +
+ + + + +
+
+
+
+ {gridOn && ( +
+ )} +
+
+ {videoPreviewError && ( +
+ {videoPreviewError} +
+ )} + {isSelectedAvi && ( +
+
{t("lab.transcode.title")}
+
{t("lab.transcode.desc")}
+ {transcodeProgress != null && ( +
+
+ {transcodeHint || t("lab.transcode.running")} +
+
+
+
+
+ )} +
+ +
+
+ )} +
+ + ) : ( + <> +
+ + + + +
+
+
+ {gridOn && ( +
+ )} + preview + +
+
+ + )} +
+ ) : ( +
+ {view === "lab_video" ? t("lab.selectOrUploadVideo") : t("lab.selectOrUpload")} +
+ )} + {busy && ( +
+ +
+ )} +
+ {((selected && view === "lab_image") || + (view === "lab_video" && + ((videoPreviewMode === "file" && selected) || videoPreviewMode === "camera"))) && ( +
+ + {t("lab.layers")} + +
+ {(["matched", "pattern", "all"] as const).map((k) => ( + + ))} +
+
+ )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && ( + <> +
+
+ + {t("lab.solveSection")} + + +
+ {resultRow ? ( +
+ {solveHud.tSolveMs != null && ( +
+
+ + {t("lab.metric.solveComputeMs")} + + + {solveHud.tSolveMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveComputeHelp")} +

+
+ )} + {lastRoundTripMs != null && ( +
+
+ + {t("lab.metric.solveRoundTripMs")} + + + {lastRoundTripMs.toFixed(0)} ms + +
+

+ {t("lab.metric.solveRoundTripHelp")} +

+
+ )} + {(solveHud.tBackendTotalMs != null || + solveHud.tOpenDecodeMs != null || + solveHud.tPreprocessMs != null || + solveHud.tExtractMs != null || + solveHud.tSolveMs != null) && ( +
+
+ + {t("lab.metric.backendTotalMs")} + + + {solveHud.tBackendTotalMs != null + ? `${solveHud.tBackendTotalMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ + {t("lab.metric.openDecodeMs")}:{" "} + {solveHud.tOpenDecodeMs != null + ? `${solveHud.tOpenDecodeMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.preprocessMs")}:{" "} + {solveHud.tPreprocessMs != null + ? `${solveHud.tPreprocessMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.extractMs")}:{" "} + {solveHud.tExtractMs != null + ? `${solveHud.tExtractMs.toFixed(0)} ms` + : t("common.placeholder")} + + + {t("lab.metric.solveOnlyMs")}:{" "} + {solveHud.tSolveMs != null + ? `${solveHud.tSolveMs.toFixed(0)} ms` + : t("common.placeholder")} + +
+
+ )} + {(solveHud.raDeg != null || solveHud.decDeg != null) && ( +
+ + {t("lab.metric.radec")} + +
+ α {formatAngleDeg(solveHud.raDeg)} · δ{" "} + {formatAngleDeg(solveHud.decDeg)} +
+
+ )} +
+ {solveHud.matches != null && ( + + + {t("lab.metric.matches")} + {" "} + {solveHud.matches} + + )} + {solveHud.rmseArcsec != null && ( + + + {t("lab.metric.rmse")} + {" "} + + {solveHud.rmseArcsec.toFixed(2)}″ + + + )} + {solveHud.prob != null && ( + + + + {t("lab.metric.prob")} + {" "} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).line} + + + + {t("lab.metric.probHelp")} + + {formatProbLine(solveHud.prob, resultRow ?? undefined).rawLine && ( + <> + + {t("lab.metric.probRaw")}:{" "} + { + formatProbLine(solveHud.prob, resultRow ?? undefined) + .rawLine + } + + + {t("lab.metric.probRawHelp")} + + + )} + + )} +
+ {solveHud.status && ( +
+ + {t("lab.metric.status")}{" "} + + {solveHud.status} +
+ )} +
+ ) : ( +

{t("common.placeholder")}

+ )} +
+
+
+ + {t("lab.imageSection")} + + +
+
+ {t("lab.resolution")} + + {previewPixelDims.w > 0 + ? `${previewPixelDims.w}×${previewPixelDims.h}` + : t("common.placeholder")} + +
+
+ {t("lab.starsDetected")} + + {starCount != null ? starCount : t("common.placeholder")} + +
+
+ {t("lab.fwhm")} + {t("common.placeholder")} +
+
+
+
+ {selected && ( + <> +
+ + {t("lab.file")}:{" "} + + {compactFilename(selected, 22, 18)} + + + {uploads.find((u) => u.filename === selected)?.source && ( + + {t("lab.source")}:{" "} + {uploads.find((u) => u.filename === selected)?.source} + + )} +
+
+ + {t("lab.meta.title")} + {metaLoading && } + + +
+ {metaCaptionRows.length > 0 ? ( +
+ {metaCaptionRows.map((row) => ( +
+
{t(row.key)}
+
+ {row.value} +
+
+ ))} +
+ ) : meta && !metaLoading ? ( +

+ {t("lab.meta.partial")} +

+ ) : !metaLoading ? ( +

+ {t("lab.meta.noSidecar")} +

+ ) : null} +
+
+ + )} + + )} + {(selected || (view === "lab_video" && videoPreviewMode === "camera")) && + (lastResult || batchPack) && ( +
+
+ {t("results.title")} +
+ {batchPack?.results?.length ? ( + + ) : null} + {lastResult && ( + + )} +
+
+
+
+ {batchPack?.results.map((r, i) => { + const row = r.result as Record | undefined; + const rawOpen = batchRawOpen[i] ?? false; + return ( +
+
+ {String(r.label)} +
+ {r.success ? ( + <> + + + {rawOpen && ( +
+                                    {JSON.stringify(r, null, 2)}
+                                  
+ )} + + ) : ( +
{String(r.error)}
+ )} + {r.success === true && selected ? ( + + ) : null} +
+ ); + })} + {lastResult && !batchPack && ( +
+
+ {t("results.title")} +
+ | undefined} + t={t} + roundTripMs={lastRoundTripMs} + /> + + {singleFooterRawOpen && ( +
+                              {JSON.stringify(lastResult, null, 2)}
+                            
+ )} +
+ )} +
+
+
+ )} +
+ + +
+ )} + + {view === "pool" && ( +
+

+ {t("pool.title")} +

+ + + + + + + + + + + + {uploads.map((u) => ( + + + + + + + + ))} + +
{t("pool.col.name")}{t("pool.col.source")}{t("pool.col.size")}{t("pool.col.time")}{t("pool.delete")}
{u.filename}{u.source ?? t("common.placeholder")}{formatFileSize(u.size)}{formatDateTime(u.modified_at, locale)} + +
+
+ )} + + {view === "history" && ( +
+

+ {t("history.intro")} +

+
+

+ {t("history.title")} +

+ setHistoryQ(e.target.value)} + /> + + + +
+
+ {t("history.total", { n: historyData?.total ?? 0 })} + + + {historyPage} / {historyTotalPages} + + +
+
    + {(historyData?.items as Array>)?.map((row) => { + const id = String(row.id ?? ""); + const metrics = row.metrics as Record | undefined; + const open = historyExpandId === id; + return ( +
  • +
    +
    +
    + {formatDateTime(String(row.created_at ?? ""), locale)} —{" "} + {String(row.input_name)} — {String(row.preset_label)} +
    +
    + {t("history.preset")}: {String(row.preset_label)} · {t("history.metrics")}:{" "} + matches={String(metrics?.matches ?? "—")} rmse= + {String(metrics?.rmse_arcsec ?? "—")} +
    +
    +
    + + +
    +
    + {open && ( +
    + {row.asset_snapshot_relpath ? ( + + ) : null} +
    +                          {JSON.stringify(row.result_json, null, 2)}
    +                        
    +
    + )} +
  • + ); + })} +
+
+ )} +
+
+ ); +} + +function SolveFooterSummary({ + result, + t, + roundTripMs, +}: { + result: Record | null | undefined; + t: (key: string, vars?: Record) => string; + roundTripMs: number | null; +}) { + const s = parseSolveResult(result ?? undefined); + if (!result) { + return

; + } + return ( +
+
+ {s.tBackendTotalMs != null && ( +
+
{t("lab.metric.backendTotalMs")}
+
+ {s.tBackendTotalMs.toFixed(0)} ms +
+
+ )} + {s.tSolveMs != null && ( +
+
{t("lab.metric.solveComputeMs")}
+
+ {s.tSolveMs.toFixed(0)} ms +
+
+ )} + {s.tOpenDecodeMs != null && ( +
+
{t("lab.metric.openDecodeMs")}
+
+ {s.tOpenDecodeMs.toFixed(0)} ms +
+
+ )} + {s.tPreprocessMs != null && ( +
+
{t("lab.metric.preprocessMs")}
+
+ {s.tPreprocessMs.toFixed(0)} ms +
+
+ )} + {s.tExtractMs != null && ( +
+
{t("lab.metric.extractMs")}
+
+ {s.tExtractMs.toFixed(0)} ms +
+
+ )} + {roundTripMs != null && ( +
+
{t("lab.metric.solveRoundTripMs")}
+
+ {roundTripMs.toFixed(0)} ms +
+
+ )} + {s.matches != null && ( +
+
{t("lab.metric.matches")}
+
{s.matches}
+
+ )} + {s.rmseArcsec != null && ( +
+
{t("lab.metric.rmse")}
+
+ {s.rmseArcsec.toFixed(2)}″ +
+
+ )} + {s.prob != null && ( +
+
{t("lab.metric.prob")}
+
+ {formatProbLine(s.prob, result).line} +
+

+ {t("lab.metric.probHelp")} +

+ {formatProbLine(s.prob, result).rawLine && ( +
+ {t("lab.metric.probRaw")}: {formatProbLine(s.prob, result).rawLine} +

+ {t("lab.metric.probRawHelp")} +

+
+ )} +
+ )} +
+
+
{t("lab.metric.radec")}
+
+ α {formatAngleDeg(s.raDeg)} · δ {formatAngleDeg(s.decDeg)} +
+
+ {s.status && ( +
+ {t("lab.metric.status")}: {s.status} +
+ )} +
+ ); +} + +function Field({ + label, + helpKey, + value, + onChange, + type = "number", + step, +}: { + label: string; + helpKey?: string; + value: number | ""; + onChange: (v: number | undefined) => void; + type?: string; + step?: number; +}) { + const { t } = useI18n(); + const help = helpKey ? t(helpKey) : undefined; + return ( + + ); +} diff --git a/web/analysis-ui/src/DebugShell.tsx b/web/analysis-ui/src/DebugShell.tsx new file mode 100644 index 0000000..a8d8229 --- /dev/null +++ b/web/analysis-ui/src/DebugShell.tsx @@ -0,0 +1,177 @@ +import type { ReactNode } from "react"; +import { + Activity, + Bolt, + Camera, + Cpu, + LayoutDashboard, + Network, + Sparkles, + Touchpad, + Wifi, +} from "lucide-react"; +import { useSystemInfo } from "./context/SystemInfoContext"; +import { useI18n } from "./i18n/I18nProvider"; + +type SystemRoute = "overview" | "network" | "sensors" | "hmi" | "power"; + +const navClass = (active: boolean) => + `flex items-center gap-3 rounded-lg px-3 py-2.5 font-headline text-sm tracking-tight transition-colors ${ + active + ? "border-r-2 border-primary bg-white/5 font-semibold text-primary" + : "text-on-surface-variant hover:bg-white/5 hover:text-on-surface" + }`; + +export function DebugShell({ + route, + onRouteChange, + children, +}: { + route: SystemRoute; + onRouteChange: (route: SystemRoute) => void; + children: ReactNode; +}) { + const { t, locale, setLocale } = useI18n(); + const { info } = useSystemInfo(); + + const cpu = info?.cpu_usage != null ? Number(info.cpu_usage).toFixed(1) : "—"; + const mem = info?.memory_usage != null ? Number(info.memory_usage).toFixed(1) : "—"; + const temp = info?.temperature != null ? Number(info.temperature).toFixed(1) : "—"; + const wifiQ = + info?.wifi_quality != null && !Number.isNaN(Number(info.wifi_quality)) + ? `${Number(info.wifi_quality).toFixed(0)}%` + : "—"; + + const routeTitle: Record = { + overview: t("sys.shell.top.overview"), + network: t("sys.shell.top.network"), + sensors: t("sys.shell.top.sensors"), + hmi: t("sys.shell.top.hmi"), + power: t("sys.shell.top.power"), + }; + const externalLinkClass = navClass(false); + const openNamedWindow = (url: string, name: string) => { + const win = window.open(url, name); + if (win) win.focus(); + }; + + return ( +
+ + +
+
+
+ + {routeTitle[route]} + +
+
+
+ + +
+
+ + CPU {cpu}% + + + MEM {mem}% + + + °C {temp} + + + {wifiQ} + +
+
+
+ +
{children}
+
+
+ ); +} diff --git a/web/analysis-ui/src/SystemConsoleApp.tsx b/web/analysis-ui/src/SystemConsoleApp.tsx new file mode 100644 index 0000000..335013a --- /dev/null +++ b/web/analysis-ui/src/SystemConsoleApp.tsx @@ -0,0 +1,49 @@ +import { useEffect, useMemo, useState } from "react"; +import { DebugShell } from "./DebugShell"; +import { OverviewPage } from "./pages/OverviewPage"; +import { NetworkPage } from "./pages/NetworkPage"; +import { PlaceholderPage } from "./pages/PlaceholderPage"; + +type SystemRoute = "overview" | "network" | "sensors" | "hmi" | "power"; + +const routeSet = new Set(["overview", "network", "sensors", "hmi", "power"]); + +function readRouteFromHash(): SystemRoute { + const raw = window.location.hash.replace(/^#\/?/, "").trim().toLowerCase(); + if (routeSet.has(raw as SystemRoute)) return raw as SystemRoute; + return "overview"; +} + +function setHashRoute(route: SystemRoute) { + window.location.hash = `/${route}`; +} + +export function SystemConsoleApp() { + const [route, setRoute] = useState(() => readRouteFromHash()); + + useEffect(() => { + const onHashChange = () => setRoute(readRouteFromHash()); + window.addEventListener("hashchange", onHashChange); + return () => window.removeEventListener("hashchange", onHashChange); + }, []); + + const page = useMemo(() => { + if (route === "network") return ; + if (route === "sensors") return ; + if (route === "hmi") return ; + if (route === "power") return ; + return ; + }, [route]); + + return ( + { + if (next === route) return; + setHashRoute(next); + }} + > + {page} + + ); +} diff --git a/web/analysis-ui/src/api.ts b/web/analysis-ui/src/api.ts new file mode 100644 index 0000000..de5217a --- /dev/null +++ b/web/analysis-ui/src/api.ts @@ -0,0 +1,386 @@ +/** OGScope Analysis Lab API client / 星空解算控制台 API */ + +const API = "/api"; + +export type UploadFileRow = { + filename: string; + size: number; + modified_at: string; + source?: string; + last_solve?: Record; +}; + +export type CentroidParams = { + sigma?: number; + max_area?: number; + min_area?: number; + filtsize?: number; + binary_open?: boolean; + max_axis_ratio?: number; +}; + +export type SolveParams = { + hint_ra_deg?: number | null; + hint_dec_deg?: number | null; + fov_estimate?: number | null; + fov_max_error?: number | null; + solve_timeout_ms?: number | null; + solve_profile?: "speed" | "balanced" | "robust" | null; + centroid?: CentroidParams | null; + max_image_side?: number | null; + /** 提星前大尺度背景减除(角部光晕等)/ Large-scale BG flatten before centroiding */ + large_scale_bg_subtract?: boolean | null; + /** 结果详细程度:summary 仅返回关键字段,full 包含 tetra 原始块 / Result detail level */ + detail_level?: "summary" | "full" | null; +}; + +async function parseJson(resp: Response): Promise { + const ct = resp.headers.get("content-type") || ""; + if (ct.includes("application/json")) { + return resp.json(); + } + const t = await resp.text(); + throw new Error(t || `HTTP ${resp.status}`); +} + +export async function fetchUploads(): Promise<{ files: UploadFileRow[] }> { + const r = await fetch(`${API}/analysis/uploads`); + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise<{ files: UploadFileRow[] }>; +} + +export async function deletePoolUpload( + filename: string, + options?: { deleteExperiments?: boolean }, +): Promise<{ deleted_experiments?: number }> { + const qs = new URLSearchParams(); + if (options?.deleteExperiments) qs.set("delete_experiments", "true"); + const q = qs.toString(); + const r = await fetch( + `${API}/analysis/uploads/${encodeURIComponent(filename)}${q ? `?${q}` : ""}`, + { method: "DELETE" }, + ); + if (!r.ok) throw new Error(await r.text()); + return (await r.json().catch(() => ({}))) as { deleted_experiments?: number }; +} + +export async function uploadFile( + file: File, + source = "analysis_upload" +): Promise<{ filename: string }> { + const fd = new FormData(); + fd.append("file", file); + fd.append("source", source); + const r = await fetch(`${API}/analysis/upload`, { method: "POST", body: fd }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as { filename: string }; +} + +export async function importFromDebug(filename: string): Promise<{ filename: string }> { + const r = await fetch(`${API}/analysis/uploads/import_from_debug`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ filename }), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as { filename: string }; +} + +export async function replaceTranscodedVideo(payload: { + old_filename: string; + new_filename: string; + duration_s?: number | null; + nominal_fps?: number | null; + codec_fourcc?: string | null; + container?: string | null; +}): Promise<{ success: boolean; filename: string }> { + const r = await fetch(`${API}/analysis/uploads/replace_video`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as { success: boolean; filename: string }; +} + +export async function solveImage( + input_name: string, + params: SolveParams +): Promise { + const r = await fetch(`${API}/analysis/solve/image`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input_name, ...params }), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data; +} + +export type BatchRun = { label: string; params: SolveParams }; + +export async function solveBatch( + input_name: string, + runs: BatchRun[] +): Promise { + const r = await fetch(`${API}/analysis/solve/batch`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input_name, runs }), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data; +} + +export async function fetchPresets(scope: "official" | "user"): Promise<{ + presets: Array<{ id: string; name: string; params: SolveParams; scope: string }>; +}> { + const r = await fetch(`${API}/analysis/presets?scope=${scope}`); + if (!r.ok) throw new Error(await r.text()); + return r.json(); +} + +export async function saveUserPreset( + name: string, + params: SolveParams +): Promise { + const r = await fetch(`${API}/analysis/presets`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, params }), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data; +} + +export async function deleteUserPreset(id: string): Promise { + const r = await fetch(`${API}/analysis/presets/${encodeURIComponent(id)}`, { + method: "DELETE", + }); + if (!r.ok) throw new Error(await r.text()); +} + +export async function saveExperiment(payload: { + input_name: string; + preset_label: string; + result_json: unknown; + metrics: Record; + thumbnail_png_base64?: string | null; + replay?: Record | null; + save_asset_snapshot?: boolean; +}): Promise { + const r = await fetch(`${API}/analysis/experiments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data; +} + +export async function deleteExperimentRecord(experimentId: string): Promise { + const r = await fetch( + `${API}/analysis/experiments/${encodeURIComponent(experimentId)}`, + { method: "DELETE" }, + ); + if (!r.ok) throw new Error(await r.text()); +} + +export async function fetchExperiments( + q: string, + page: number, + pageSize = 30, +): Promise<{ items: unknown[]; total: number; page_size?: number }> { + const qs = new URLSearchParams({ + page: String(page), + page_size: String(pageSize), + }); + if (q) qs.set("q", q); + const r = await fetch(`${API}/analysis/experiments?${qs}`); + if (!r.ok) throw new Error(await r.text()); + return r.json(); +} + +export type DebugFileRow = { + name: string; + size: number; + modified: string; + type: string; +}; + +export async function fetchDebugFiles(): Promise<{ files: DebugFileRow[] }> { + const r = await fetch(`${API}/debug/files`); + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise<{ files: DebugFileRow[] }>; +} + +export function debugCaptureFileUrl(filename: string): string { + return `${API}/debug/files/${encodeURIComponent(filename)}`; +} + +export async function fetchDebugFileInfo( + filename: string, +): Promise> { + const r = await fetch( + `${API}/debug/files/${encodeURIComponent(filename)}/info`, + ); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as Record; +} + +export async function fetchUploadFileInfo( + filename: string, +): Promise> { + const r = await fetch( + `${API}/analysis/uploads/${encodeURIComponent(filename)}/info`, + ); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as Record; +} + +export function uploadFileUrl(filename: string): string { + return `${API}/analysis/uploads/file?filename=${encodeURIComponent(filename)}`; +} + +export async function exportExperiments(fmt: "json" | "csv"): Promise { + const r = await fetch(`${API}/analysis/experiments/export?format=${fmt}`); + if (!r.ok) throw new Error(await r.text()); + return r.text(); +} + + +export async function fetchUploadExperimentCount( + filename: string, +): Promise<{ count: number }> { + const r = await fetch( + `${API}/analysis/uploads/${encodeURIComponent(filename)}/experiment_count`, + ); + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise<{ count: number }>; +} + +export type LabPublicSettings = { + solver_timeout_ms: number; + star_analysis_target_fps: number; + star_analysis_min_interval_ms: number; + star_analysis_max_interval_ms: number; + star_analysis_request_timeout_ms: number; + star_analysis_slow_threshold_ms: number; + camera_width: number; + camera_height: number; + camera_fps: number; + solver_fov_deg: number; + solver_max_image_side: number; + solver_large_scale_bg_downsample?: number; +}; + +export async function fetchLabSettings(): Promise { + const r = await fetch(`${API}/analysis/settings`); + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise; +} + +export type SolveVideoFrameSource = "camera" | "file"; + +export async function solveVideoFrame(payload: { + source: SolveVideoFrameSource; + input_name?: string | null; + frame_index?: number; + time_sec?: number | null; + solve_interval_ms?: number | null; + /** 自动标注 Top-N 星点(省略则用后端默认) / Auto-label top-N stars (server default if omitted) */ + overlay_topn_count?: number | null; + /** 是否启用极轴引导信息(省略则用后端默认) / Whether to enable polar guide info (server default if omitted) */ + enable_polar_guide?: boolean | null; +} & SolveParams): Promise<{ + success: boolean; + result?: Record; + frame_id?: number | null; + frame_ts?: string | null; + gate_status?: string | null; + gate_reason?: string | null; + next_allowed_in_ms?: number | null; + requested_interval_ms?: number | null; + effective_interval_ms?: number | null; +}> { + const r = await fetch(`${API}/analysis/solve/frame`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as { + success: boolean; + result?: Record; + frame_id?: number | null; + frame_ts?: string | null; + gate_status?: string | null; + gate_reason?: string | null; + next_allowed_in_ms?: number | null; + requested_interval_ms?: number | null; + effective_interval_ms?: number | null; + }; +} + +export async function solveFrameFromBlob( + frameBlob: Blob, + payload: SolveParams & { + overlay_topn_count?: number | null; + enable_polar_guide?: boolean | null; + solve_interval_ms?: number | null; + }, +): Promise<{ + success: boolean; + result?: Record; + gate_status?: string | null; + gate_reason?: string | null; + requested_interval_ms?: number | null; + effective_interval_ms?: number | null; +}> { + const fd = new FormData(); + fd.append("file", frameBlob, "frame.jpg"); + fd.append("payload", JSON.stringify(payload)); + const r = await fetch(`${API}/analysis/solve/frame_upload`, { + method: "POST", + body: fd, + }); + const data = await parseJson(r); + if (!r.ok) throw new Error(String((data as { detail?: string }).detail || r.status)); + return data as { + success: boolean; + result?: Record; + gate_status?: string | null; + gate_reason?: string | null; + requested_interval_ms?: number | null; + effective_interval_ms?: number | null; + }; +} + +export function experimentAssetUrl(experimentId: string): string { + return `${API}/analysis/experiments/${encodeURIComponent(experimentId)}/asset`; +} + +export type SystemInfo = { + platform: string; + os: string; + cpu_usage: number; + memory_usage: number; + temperature: number; + uptime_seconds?: number; + load_average_1m?: number; +}; + +export async function fetchSystemInfo(): Promise { + const r = await fetch(`${API}/system/info`); + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise; +} diff --git a/web/analysis-ui/src/camera-main.tsx b/web/analysis-ui/src/camera-main.tsx new file mode 100644 index 0000000..af34581 --- /dev/null +++ b/web/analysis-ui/src/camera-main.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { I18nProvider } from "./i18n/I18nProvider"; +import { SystemInfoProvider } from "./context/SystemInfoContext"; +import { CameraConsoleApp } from "./camera/CameraConsoleApp"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/web/analysis-ui/src/camera/CameraConsoleApp.tsx b/web/analysis-ui/src/camera/CameraConsoleApp.tsx new file mode 100644 index 0000000..926dc3f --- /dev/null +++ b/web/analysis-ui/src/camera/CameraConsoleApp.tsx @@ -0,0 +1,1366 @@ +import { useEffect, useRef, useState } from "react"; +import { + Camera, + Circle, + Download, + FileText, + FolderOpen, + Info, + Moon, + Play, + Save, + Settings2, + SlidersHorizontal, + Square, + Sun, + Trash2, + X, +} from "lucide-react"; +import { useI18n } from "../i18n/I18nProvider"; +import { useSystemInfo } from "../context/SystemInfoContext"; +import { requestJson } from "../systemApi"; + +type CameraInfo = { + exposure_us?: number; + analogue_gain?: number; + digital_gain?: number; + auto_exposure?: boolean; + contrast?: number; + brightness?: number; + saturation?: number; + sharpness?: number; + noise_reduction?: number; + white_balance_mode?: string; + white_balance_gain_r?: number; + white_balance_gain_b?: number; + color_mode?: string; + rotation?: number; + width?: number; + height?: number; + fps?: number; + sampling_mode?: string; + sensor?: string; + [key: string]: unknown; +}; + +type CameraStatus = { + streaming?: boolean; + recording?: boolean; + camera_ready?: boolean; + info?: CameraInfo; + runtime_overrides?: Record; +}; + +type StreamStats = { + requestCount: number; + frameCount: number; + requestFps: number; + effectiveFps: number; + lastRequestTime: number | null; + lastFrameTime: number | null; + requestSamples: number[]; + frameSamples: number[]; +}; + +type CameraForm = { + exposure: number; + gain: number; + digitalGain: number; + autoExposure: boolean; + contrast: number; + brightness: number; + saturation: number; + sharpness: number; + noiseReduction: number; + whiteBalanceMode: string; + whiteBalanceGainR: number; + whiteBalanceGainB: number; + colorMode: string; +}; + +type CameraPreset = { + name: string; + description?: string; + exposure_us: number; + analogue_gain: number; + digital_gain?: number; + auto_exposure?: boolean; + contrast?: number; + brightness?: number; + saturation?: number; + sharpness?: number; + noise_reduction?: number; + white_balance_mode?: string; + white_balance_gain_r?: number; + white_balance_gain_b?: number; + rotation?: number; + color_mode?: string; +}; + +type DebugFileItem = { + name: string; + size: number; + modified: string; + type: "image" | "video"; +}; + +type DebugFileInfo = { + filename: string; + size: number; + modified: string; + type: "image" | "video"; + exposure_us?: number; + analogue_gain?: number; + digital_gain?: number; + resolution?: string; + duration_s?: number; + fps?: number; +}; + +const RES_PRESETS = ["640x360", "1280x720", "1600x900", "1920x1080"] as const; +const ROTATION_PRESETS = [0, 90, 180, 270] as const; +const FILE_PAGE_SIZE = 12; + +function clamp(v: number, min: number, max: number): number { + return Math.min(max, Math.max(min, v)); +} + +function toNum(v: unknown, fallback: number): number { + if (v == null || Number.isNaN(Number(v))) return fallback; + return Number(v); +} + +function formatSize(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + let idx = 0; + let val = bytes; + while (val >= 1024 && idx < units.length - 1) { + val /= 1024; + idx += 1; + } + return `${val.toFixed(idx === 0 ? 0 : 1)} ${units[idx]}`; +} + +function ParamSlider({ + label, + value, + min, + max, + step, + onChange, + disabled = false, + unit = "", +}: { + label: string; + value: number; + min: number; + max: number; + step: number; + onChange: (value: number) => void; + disabled?: boolean; + unit?: string; +}) { + return ( + + ); +} + +export function CameraConsoleApp() { + const { t, locale, setLocale } = useI18n(); + const { info: sysInfo } = useSystemInfo(); + const [status, setStatus] = useState(null); + const [previewActive, setPreviewActive] = useState(false); + const [streamNonce, setStreamNonce] = useState(() => Date.now()); + const [err, setErr] = useState(null); + const [notice, setNotice] = useState(null); + const [previewBusy, setPreviewBusy] = useState(false); + const [recordBusy, setRecordBusy] = useState(false); + const [captureBusy, setCaptureBusy] = useState(false); + const [fpsValue, setFpsValue] = useState("5"); + const [resValue, setResValue] = useState("1280x720"); + const [samplingMode, setSamplingMode] = useState("supersample"); + const [runtimeDirty, setRuntimeDirty] = useState(false); + const [showHistogram, setShowHistogram] = useState(true); + const [showRgb, setShowRgb] = useState(true); + const [showLuminance, setShowLuminance] = useState(false); + const [showOverExposure, setShowOverExposure] = useState(false); + const [histCollapsed, setHistCollapsed] = useState(false); + const [histStats, setHistStats] = useState({ mean: 0, std: 0, over: 0 }); + const [actualFps, setActualFps] = useState(0); + const [recordElapsed, setRecordElapsed] = useState(0); + const [rotationValue, setRotationValue] = useState(180); + const [form, setForm] = useState({ + exposure: 5000, + gain: 1.0, + digitalGain: 1.0, + autoExposure: true, + contrast: 1.0, + brightness: 0.0, + saturation: 1.0, + sharpness: 1.0, + noiseReduction: 0, + whiteBalanceMode: "auto", + whiteBalanceGainR: 1.0, + whiteBalanceGainB: 1.0, + colorMode: "color", + }); + const [formDirty, setFormDirty] = useState(false); + const [presetName, setPresetName] = useState(""); + const [presetDesc, setPresetDesc] = useState(""); + const [presets, setPresets] = useState([]); + const [presetBusy, setPresetBusy] = useState(false); + const [files, setFiles] = useState([]); + const [fileBusy, setFileBusy] = useState(false); + const [fileInfo, setFileInfo] = useState(null); + const [fileInfoBusy, setFileInfoBusy] = useState(false); + /** 当前展开详情的列表项文件名(与 API 返回的 filename 可能不同)/ Key for which row detail is open */ + const [fileDetailKey, setFileDetailKey] = useState(null); + const [filePage, setFilePage] = useState(1); + + const imgRef = useRef(null); + const histogramCanvasRef = useRef(null); + const offscreenCanvasRef = useRef(null); + const offscreenCtxRef = useRef(null); + const histogramCtxRef = useRef(null); + const recordTickRef = useRef(null); + const reconnectTimerRef = useRef(null); + const previewActiveRef = useRef(false); + const streamStartedAtRef = useRef(null); + const fpsSampleRef = useRef<{ ts: number; frames: number }>({ + ts: performance.now(), + frames: 0, + }); + const statsRef = useRef({ + requestCount: 0, + frameCount: 0, + requestFps: 0, + effectiveFps: 0, + lastRequestTime: null, + lastFrameTime: null, + requestSamples: [], + frameSamples: [], + }); + + const resetStreamStats = () => { + statsRef.current = { + requestCount: 0, + frameCount: 0, + requestFps: 0, + effectiveFps: 0, + lastRequestTime: null, + lastFrameTime: null, + requestSamples: [], + frameSamples: [], + }; + fpsSampleRef.current = { ts: performance.now(), frames: 0 }; + streamStartedAtRef.current = null; + setActualFps(0); + }; + + const updateCameraStatus = async () => { + try { + const next = await requestJson("/api/debug/camera/status", { cache: "no-store" }); + setStatus(next); + if (!next.streaming) { + setPreviewActive(false); + previewActiveRef.current = false; + } + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current != null) { + window.clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const startPreview = async () => { + if (previewBusy) return; + setPreviewBusy(true); + setErr(null); + try { + clearReconnectTimer(); + if (!status?.streaming) { + await requestJson("/api/debug/camera/start", { method: "POST" }); + } + setPreviewActive(true); + previewActiveRef.current = true; + setNotice(t("cam.notice.previewStart")); + resetStreamStats(); + streamStartedAtRef.current = performance.now(); + setStreamNonce(Date.now()); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPreviewBusy(false); + } + }; + + const stopPreview = async () => { + if (previewBusy) return; + setPreviewBusy(true); + setErr(null); + try { + // 先卸载预览,释放长连接,再通知后端停止 / Release stream before stop API + clearReconnectTimer(); + setPreviewActive(false); + previewActiveRef.current = false; + resetStreamStats(); + setStatus((prev) => (prev ? { ...prev, streaming: false, recording: false } : prev)); + if (imgRef.current) { + imgRef.current.src = ""; + } + setStreamNonce(Date.now()); + await new Promise((resolve) => window.requestAnimationFrame(() => resolve())); + await requestJson("/api/debug/camera/stop", { method: "POST" }); + setNotice(t("cam.notice.previewStop")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPreviewBusy(false); + } + }; + + const capture = async () => { + if (!previewActive && !status?.streaming) return; + setCaptureBusy(true); + setErr(null); + try { + const data = await requestJson<{ filename?: string }>("/api/debug/camera/capture", { method: "POST" }); + setNotice(t("cam.notice.captureSaved", { name: data.filename || "capture" })); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setCaptureBusy(false); + } + }; + + const toggleRecord = async () => { + if (recordBusy) return; + setRecordBusy(true); + setErr(null); + try { + if (status?.recording) { + await requestJson("/api/debug/camera/record/stop", { method: "POST" }); + setNotice(t("cam.notice.recordStop")); + } else { + const data = await requestJson<{ filename?: string }>("/api/debug/camera/record/start", { method: "POST" }); + setNotice(t("cam.notice.recordStart", { name: data.filename || "video.avi" })); + } + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setRecordBusy(false); + } + }; + + + const applyRuntimeSettings = async () => { + setErr(null); + try { + const fps = clamp(parseInt(fpsValue, 10) || 5, 1, 60); + await requestJson(`/api/debug/camera/fps?fps=${fps}`, { method: "POST" }); + const [w, h] = resValue.split("x").map((x) => parseInt(x, 10)); + if (w && h) { + await requestJson(`/api/debug/camera/size?width=${w}&height=${h}`, { method: "POST" }); + } + await requestJson(`/api/debug/camera/sampling?mode=${encodeURIComponent(samplingMode)}`, { + method: "POST", + }); + setNotice(t("cam.notice.runtimeApplied")); + setRuntimeDirty(false); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const applyCoreSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + setNotice(t("cam.notice.settingsApplied")); + setFormDirty(false); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const applyModeSettings = async () => { + setErr(null); + try { + await requestJson(`/api/debug/camera/auto-exposure?enabled=${form.autoExposure ? "true" : "false"}`, { + method: "POST", + }); + await requestJson( + `/api/debug/camera/white-balance?mode=${encodeURIComponent(form.whiteBalanceMode)}&gain_r=${form.whiteBalanceGainR}&gain_b=${form.whiteBalanceGainB}`, + { method: "POST" }, + ); + await requestJson(`/api/debug/camera/color-mode?color_mode=${encodeURIComponent(form.colorMode)}`, { + method: "POST", + }); + setNotice(t("cam.notice.modeApplied")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const syncFormFromStatus = (info: CameraInfo | undefined) => { + if (!info) return; + setForm({ + exposure: clamp(Math.round(toNum(info.exposure_us, 5000)), 100, 120000), + gain: clamp(toNum(info.analogue_gain, 1.0), 1.0, 24.0), + digitalGain: clamp(toNum(info.digital_gain, 1.0), 1.0, 8.0), + autoExposure: Boolean(info.auto_exposure ?? true), + contrast: clamp(toNum(info.contrast, 1.0), 0, 2), + brightness: clamp(toNum(info.brightness, 0.0), -1, 1), + saturation: clamp(toNum(info.saturation, 1.0), 0, 2), + sharpness: clamp(toNum(info.sharpness, 1.0), 0, 2), + noiseReduction: clamp(Math.round(toNum(info.noise_reduction, 0)), 0, 4), + whiteBalanceMode: String(info.white_balance_mode ?? "auto"), + whiteBalanceGainR: clamp(toNum(info.white_balance_gain_r, 1.0), 0.1, 3.0), + whiteBalanceGainB: clamp(toNum(info.white_balance_gain_b, 1.0), 0.1, 3.0), + colorMode: String(info.color_mode ?? "color"), + }); + setFpsValue(String(Math.round(toNum(info.fps, 5)))); + setResValue(`${Math.round(toNum(info.width, 1280))}x${Math.round(toNum(info.height, 720))}`); + setSamplingMode(String(info.sampling_mode ?? "supersample")); + setRuntimeDirty(false); + setRotationValue(clamp(Math.round(toNum(info.rotation, 180)), 0, 270)); + setFormDirty(false); + }; + + const applyRotation = async (rotation: number) => { + setErr(null); + try { + await requestJson(`/api/debug/camera/rotation/${rotation}`, { method: "POST" }); + setRotationValue(rotation); + setNotice(t("cam.notice.rotationApplied", { value: rotation })); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const toggleNightMode = async (enabled: boolean) => { + setErr(null); + try { + await requestJson(`/api/debug/camera/night-mode?enabled=${enabled ? "true" : "false"}`, { + method: "POST", + }); + setNotice(enabled ? t("cam.notice.nightOn") : t("cam.notice.nightOff")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const resetSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/reset", { method: "POST" }); + setNotice(t("cam.notice.reset")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const backupSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/backup-settings", { method: "POST" }); + setNotice(t("cam.notice.backup")); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const restoreSettings = async () => { + setErr(null); + try { + await requestJson("/api/debug/camera/restore-settings", { method: "POST" }); + setNotice(t("cam.notice.restore")); + await updateCameraStatus(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const loadPresets = async () => { + setPresetBusy(true); + try { + const data = await requestJson<{ presets?: CameraPreset[] }>("/api/debug/camera/presets", { cache: "no-store" }); + setPresets(data.presets ?? []); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const savePreset = async () => { + const name = presetName.trim(); + if (!name) return; + setPresetBusy(true); + setErr(null); + try { + await requestJson("/api/debug/camera/presets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + description: presetDesc.trim(), + exposure_us: form.exposure, + analogue_gain: form.gain, + digital_gain: form.digitalGain, + auto_exposure: form.autoExposure, + contrast: form.contrast, + brightness: form.brightness, + saturation: form.saturation, + sharpness: form.sharpness, + noise_reduction: form.noiseReduction, + white_balance_mode: form.whiteBalanceMode, + white_balance_gain_r: form.whiteBalanceGainR, + white_balance_gain_b: form.whiteBalanceGainB, + rotation: rotationValue, + color_mode: form.colorMode, + }), + }); + setNotice(t("cam.notice.presetSaved", { name })); + setPresetName(""); + setPresetDesc(""); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const applyPreset = async (name: string) => { + setPresetBusy(true); + setErr(null); + try { + await requestJson(`/api/debug/camera/presets/${encodeURIComponent(name)}/apply`, { method: "POST" }); + setNotice(t("cam.notice.presetApplied", { name })); + await updateCameraStatus(); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const deletePreset = async (name: string) => { + if (!window.confirm(t("cam.confirm.deletePreset", { name }))) return; + setPresetBusy(true); + setErr(null); + try { + await requestJson(`/api/debug/camera/presets/${encodeURIComponent(name)}`, { method: "DELETE" }); + setNotice(t("cam.notice.presetDeleted", { name })); + await loadPresets(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setPresetBusy(false); + } + }; + + const loadFiles = async () => { + setFileBusy(true); + try { + const data = await requestJson<{ files?: DebugFileItem[] }>("/api/debug/files", { cache: "no-store" }); + setFiles(data.files ?? []); + setFilePage(1); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } finally { + setFileBusy(false); + } + }; + + const closeFileInfo = () => { + setFileDetailKey(null); + setFileInfo(null); + setFileInfoBusy(false); + }; + + const showFileInfo = async (name: string) => { + // 再次点击同一行:关闭详情 / Toggle same row: close detail + if (fileDetailKey === name) { + closeFileInfo(); + return; + } + setFileDetailKey(name); + setFileInfo(null); + setFileInfoBusy(true); + setErr(null); + try { + const data = await requestJson(`/api/debug/files/${encodeURIComponent(name)}/info`, { cache: "no-store" }); + setFileInfo(data); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + setFileDetailKey(null); + } finally { + setFileInfoBusy(false); + } + }; + + const downloadFile = (name: string) => { + const triggerDownload = (filename: string, href: string) => { + const a = document.createElement("a"); + a.href = href; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + }; + triggerDownload(name, `/api/debug/files/${encodeURIComponent(name)}`); + const mediaMatch = name.match(/\.(jpe?g|png|bmp|tiff?|webp|mp4|avi|mov|mkv|wmv|flv|webm|m4v)$/i); + if (mediaMatch) { + const stem = name.slice(0, -mediaMatch[0].length); + const sidecar = `${stem}.txt`; + void (async () => { + try { + const res = await fetch(`/api/debug/files/${encodeURIComponent(sidecar)}`); + if (!res.ok) return; + triggerDownload(sidecar, `/api/debug/files/${encodeURIComponent(sidecar)}`); + setNotice(t("cam.notice.downloadWithSidecar", { name, sidecar })); + } catch { + setNotice(t("cam.notice.download", { name })); + } + })(); + return; + } + setNotice(t("cam.notice.download", { name })); + }; + + const deleteFile = async (name: string) => { + if (!window.confirm(t("cam.confirm.deleteFile", { name }))) return; + setErr(null); + try { + await requestJson(`/api/debug/files/${encodeURIComponent(name)}`, { method: "DELETE" }); + setNotice(t("cam.notice.fileDeleted", { name })); + if (fileDetailKey === name || fileInfo?.filename === name) closeFileInfo(); + await loadFiles(); + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)); + } + }; + + const analyzeStreamData = () => { + const now = performance.now(); + const s = statsRef.current; + s.requestCount += 1; + if (s.lastRequestTime != null) { + const diff = now - s.lastRequestTime; + if (diff > 10) { + s.requestSamples.push(1000 / diff); + if (s.requestSamples.length > 10) s.requestSamples.shift(); + s.requestFps = s.requestSamples.reduce((a, b) => a + b, 0) / s.requestSamples.length; + } + } + s.lastRequestTime = now; + + s.frameCount += 1; + if (s.lastFrameTime != null) { + const diff = now - s.lastFrameTime; + if (diff > 10) { + let fps = 1000 / diff; + const reported = Number(status?.info?.fps ?? 5) || 5; + fps = Math.min(fps, Math.max(10, reported * 2)); + s.frameSamples.push(fps); + if (s.frameSamples.length > 10) s.frameSamples.shift(); + s.effectiveFps = s.frameSamples.reduce((a, b) => a + b, 0) / s.frameSamples.length; + } + } + s.lastFrameTime = now; + }; + + const updateHistogramFromImage = () => { + if (!showHistogram || !imgRef.current || !histogramCanvasRef.current) return; + const imageElement = imgRef.current; + if (!imageElement.naturalWidth || !imageElement.naturalHeight) return; + + if (!offscreenCanvasRef.current) { + offscreenCanvasRef.current = document.createElement("canvas"); + offscreenCtxRef.current = offscreenCanvasRef.current.getContext("2d", { willReadFrequently: true }); + } + if (!histogramCtxRef.current) { + histogramCtxRef.current = histogramCanvasRef.current.getContext("2d"); + } + if (!offscreenCtxRef.current || !histogramCtxRef.current) return; + + const maxSampleWidth = 320; + const scale = Math.min(1, maxSampleWidth / imageElement.naturalWidth); + const sampleWidth = Math.max(1, Math.round(imageElement.naturalWidth * scale)); + const sampleHeight = Math.max(1, Math.round(imageElement.naturalHeight * scale)); + offscreenCanvasRef.current.width = sampleWidth; + offscreenCanvasRef.current.height = sampleHeight; + offscreenCtxRef.current.drawImage(imageElement, 0, 0, sampleWidth, sampleHeight); + const imageData = offscreenCtxRef.current.getImageData(0, 0, sampleWidth, sampleHeight).data; + + const histR = new Array(256).fill(0); + const histG = new Array(256).fill(0); + const histB = new Array(256).fill(0); + const histL = new Array(256).fill(0); + let lumSum = 0; + let lumSq = 0; + let over = 0; + const pixelsCount = sampleWidth * sampleHeight; + + for (let i = 0; i < imageData.length; i += 4) { + const r = imageData[i]; + const g = imageData[i + 1]; + const b = imageData[i + 2]; + const lum = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); + histR[r] += 1; + histG[g] += 1; + histB[b] += 1; + histL[lum] += 1; + lumSum += lum; + lumSq += lum * lum; + if (lum >= 250) over += 1; + } + + const mean = pixelsCount ? lumSum / pixelsCount : 0; + const variance = pixelsCount ? lumSq / pixelsCount - mean * mean : 0; + setHistStats({ mean, std: Math.sqrt(Math.max(0, variance)), over: pixelsCount ? (over / pixelsCount) * 100 : 0 }); + + const canvas = histogramCanvasRef.current; + const ctx = histogramCtxRef.current; + const dpr = window.devicePixelRatio || 1; + const w = Math.max(1, canvas.clientWidth || 240); + const h = Math.max(1, canvas.clientHeight || 120); + canvas.width = Math.floor(w * dpr); + canvas.height = Math.floor(h * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + const peak = Math.max( + 1, + ...(showRgb ? [Math.max(...histR), Math.max(...histG), Math.max(...histB)] : [0]), + ...(showLuminance ? [Math.max(...histL)] : [0]), + ); + + const draw = (hist: number[], color: string) => { + ctx.beginPath(); + ctx.strokeStyle = color; + ctx.lineWidth = 1.2; + for (let i = 0; i < 256; i += 1) { + const x = (i / 255) * w; + const y = h - (hist[i] / peak) * h; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }; + + if (showRgb) { + draw(histR, "rgba(255,80,80,0.85)"); + draw(histG, "rgba(80,255,80,0.85)"); + draw(histB, "rgba(80,160,255,0.85)"); + } + if (showLuminance) draw(histL, "rgba(255,255,255,0.95)"); + if (showOverExposure) { + const warnX = (250 / 255) * w; + ctx.fillStyle = "rgba(255,100,100,0.12)"; + ctx.fillRect(warnX, 0, w - warnX, h); + } + }; + + useEffect(() => { + void updateCameraStatus(); + void loadPresets(); + void loadFiles(); + const statusPoll = window.setInterval(() => { + if (!document.hidden) { + void updateCameraStatus(); + } + }, 2000); + return () => window.clearInterval(statusPoll); + }, [status?.streaming]); + + useEffect(() => { + if (!status?.info || formDirty) return; + syncFormFromStatus(status.info); + }, [status?.info, formDirty]); + + useEffect(() => { + if (!status?.recording) { + if (recordTickRef.current) { + window.clearInterval(recordTickRef.current); + recordTickRef.current = null; + } + setRecordElapsed(0); + return; + } + if (recordTickRef.current) return; + const start = Date.now(); + recordTickRef.current = window.setInterval(() => { + setRecordElapsed(Math.max(0, Math.floor((Date.now() - start) / 1000))); + }, 1000); + return () => { + if (!recordTickRef.current) return; + window.clearInterval(recordTickRef.current); + recordTickRef.current = null; + }; + }, [status?.recording]); + + useEffect(() => { + previewActiveRef.current = previewActive; + if (!previewActive) { + clearReconnectTimer(); + } + }, [previewActive]); + + useEffect(() => { + if (!previewActive) return; + const watchdog = window.setInterval(() => { + const last = statsRef.current.lastFrameTime; + if (last != null && performance.now() - last > 2000) { + void updateCameraStatus(); + setStreamNonce(Date.now()); + } + }, 1000); + return () => window.clearInterval(watchdog); + }, [previewActive]); + + useEffect(() => { + if (!previewActive) { + setActualFps(0); + fpsSampleRef.current = { ts: performance.now(), frames: statsRef.current.frameCount }; + return; + } + const timer = window.setInterval(() => { + const now = performance.now(); + const currentFrames = statsRef.current.frameCount; + const dt = (now - fpsSampleRef.current.ts) / 1000; + const df = currentFrames - fpsSampleRef.current.frames; + if (dt > 0.2) { + setActualFps(Math.max(0, df / dt)); + fpsSampleRef.current = { ts: now, frames: currentFrames }; + } + }, 1000); + return () => window.clearInterval(timer); + }, [previewActive]); + + useEffect(() => { + if (!notice) return; + const timer = window.setTimeout(() => setNotice(null), 3200); + return () => window.clearTimeout(timer); + }, [notice]); + + useEffect(() => () => clearReconnectTimer(), []); + + useEffect(() => { + const total = Math.max(1, Math.ceil(files.length / FILE_PAGE_SIZE)); + if (filePage > total) { + setFilePage(total); + } + }, [files.length, filePage]); + + const streamSrc = previewActive ? `/api/debug/camera/stream?t=${streamNonce}` : ""; + const s = statsRef.current; + const exposureLocked = form.autoExposure; + const wbManual = form.whiteBalanceMode === "manual"; + const nightModeEnabled = Boolean(status?.info?.night_mode); + const isStreaming = previewActive; + const canStartPreview = !previewBusy && !previewActive && !Boolean(status?.recording); + const canStopPreview = !previewBusy && previewActive; + const canCapture = !previewBusy && !captureBusy && isStreaming; + const canRecordToggle = !previewBusy && !recordBusy && isStreaming; + const totalFilePages = Math.max(1, Math.ceil(files.length / FILE_PAGE_SIZE)); + const filePageClamped = Math.min(filePage, totalFilePages); + const fileStart = (filePageClamped - 1) * FILE_PAGE_SIZE; + const pagedFiles = files.slice(fileStart, fileStart + FILE_PAGE_SIZE); + return ( +
+
+
+
+

{`OGScope ${t("cam.title")}`}

+
+
+ + + + {t("cam.btn.system")} + +
+
+
+ +
+ + +
+
+
+
+
+ CPU: {Number(sysInfo?.cpu_usage ?? 0).toFixed(1)}% +
+
+ MEM: {Number(sysInfo?.memory_usage ?? 0).toFixed(1)}% +
+
+ TEMP: {Number(sysInfo?.temperature ?? 0).toFixed(1)}°C +
+
+
+

{t("cam.preview.title")}

+
+ {t("cam.preview.state")}: {status?.streaming ? t("cam.state.streaming") : t("cam.state.idle")} +
+
+
+ {previewActive ? ( + camera-preview { + analyzeStreamData(); + updateHistogramFromImage(); + }} + onError={() => { + clearReconnectTimer(); + if (!previewActiveRef.current) return; + reconnectTimerRef.current = window.setTimeout(() => { + if (previewActiveRef.current) { + setStreamNonce(Date.now()); + } + }, 400); + }} + /> + ) : ( +
+ +
{t("cam.preview.emptyTitle")}
+
{t("cam.preview.emptyDesc")}
+ +
+ )} + {status?.recording && ( +
+ {t("cam.state.rec")} + {`${Math.floor(recordElapsed / 60).toString().padStart(2, "0")}:${(recordElapsed % 60).toString().padStart(2, "0")}`} +
+ )} +
+ +
+ {!histCollapsed && ( +
+
+ + + + +
+ +
+
mean: {histStats.mean.toFixed(1)}
+
std: {histStats.std.toFixed(1)}
+
over: {histStats.over.toFixed(2)}%
+
+
+ )} +
+
+
+ {t("cam.stats.frameFps")}: + {actualFps.toFixed(2)} +
+
+ {t("cam.stats.targetFps")}: + {Number(status?.info?.fps ?? 0).toFixed(2)} +
+
+ {t("cam.stats.frameCount")}: + {s.frameCount} +
+
+ {t("cam.stats.uptime")}: + {streamStartedAtRef.current != null ? `${Math.max(0, Math.round((performance.now() - streamStartedAtRef.current) / 1000))}s` : "0s"} +
+
+ {t("cam.system.sensor")}: + {String(status?.info?.sensor ?? "—")} +
+
+ {t("cam.controls.resolution")}: + {`${status?.info?.width ?? "—"}x${status?.info?.height ?? "—"}`} +
+
+ {t("cam.preview.mode")}: + {status?.info?.auto_exposure ? t("cam.controls.auto") : t("cam.controls.manual")} +
+
+
+ + + + +
+
+ +
+

{t("cam.files.title")}

+
+ +
+ {fileInfoBusy &&
{t("cam.files.loadingInfo")}
} + {fileInfo && ( +
+
+
+ + {fileInfo.filename} +
+ +
+
{t("cam.files.size")}: {formatSize(fileInfo.size)}
+
{t("cam.files.type")}: {fileInfo.type}
+
{t("cam.files.modified")}: {new Date(fileInfo.modified).toLocaleString()}
+ {fileInfo.exposure_us != null &&
{t("cam.controls.exposure")}: {fileInfo.exposure_us}us
} + {fileInfo.analogue_gain != null &&
{t("cam.controls.gain")}: {fileInfo.analogue_gain}
} + {fileInfo.resolution &&
{t("cam.controls.resolution")}: {fileInfo.resolution}
} +
+ )} +
+ {files.length === 0 && !fileBusy &&
{t("cam.files.empty")}
} + {pagedFiles.map((f) => ( +
+
+
+
{f.name}
+
{formatSize(f.size)} | {new Date(f.modified).toLocaleString()}
+
+
{f.type}
+
+
+ + + +
+
+ ))} +
+
+ +
{t("cam.files.page", { current: filePageClamped, total: totalFilePages })}
+ +
+
+
+ +
+
+

{t("cam.controls.title")}

+
+
+ + +
+ + +
+
+ +
+
+ {t("cam.controls.core")} +
+ {formDirty && ( +
+ {t("cam.controls.pendingChanges")} +
+ )} + {exposureLocked &&

{t("cam.controls.lockedByAe")}

} +
+ { setFormDirty(true); setForm((p) => ({ ...p, exposure: v })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, gain: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, digitalGain: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, noiseReduction: Math.round(v) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, contrast: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, brightness: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, saturation: Number(v.toFixed(1)) })); }} /> + { setFormDirty(true); setForm((p) => ({ ...p, sharpness: Number(v.toFixed(1)) })); }} /> +
+
+ +
+
+ +
+
{t("cam.controls.mode")}
+
+ + + + { setForm((p) => ({ ...p, whiteBalanceGainR: Number(v.toFixed(1)) })); setFormDirty(true); }} /> + { setForm((p) => ({ ...p, whiteBalanceGainB: Number(v.toFixed(1)) })); setFormDirty(true); }} /> +
+ {!wbManual &&

{t("cam.controls.lockedByWb")}

} +
+ +
+
+
+
+
+ + {(err || notice) && ( +
+ {err &&
{err}
} + {notice &&
{notice}
} +
+ )} +
+ ); +} diff --git a/web/analysis-ui/src/context/SystemInfoContext.tsx b/web/analysis-ui/src/context/SystemInfoContext.tsx new file mode 100644 index 0000000..21f6369 --- /dev/null +++ b/web/analysis-ui/src/context/SystemInfoContext.tsx @@ -0,0 +1,70 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; + +/** 与 /api/system/info 对齐的宽松类型 / Loose shape aligned with system info API */ +export type SystemInfoRecord = Record; + +type Ctx = { + info: SystemInfoRecord | null; + error: string | null; + refresh: () => Promise; +}; + +const SystemInfoContext = createContext(null); + +const POLL_MS = 8000; + +async function fetchInfo(): Promise { + const r = await fetch("/api/system/info", { cache: "no-store" }); + let data: unknown = {}; + try { + data = await r.json(); + } catch { + // ignore + } + if (!r.ok) { + const d = data as { detail?: string }; + throw new Error(d.detail || `HTTP ${r.status}`); + } + return data as SystemInfoRecord; +} + +export function SystemInfoProvider({ children }: { children: ReactNode }) { + const [info, setInfo] = useState(null); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + try { + setError(null); + const j = await fetchInfo(); + setInfo(j); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } + }, []); + + useEffect(() => { + void refresh(); + const id = window.setInterval(() => void refresh(), POLL_MS); + return () => window.clearInterval(id); + }, [refresh]); + + const value = useMemo(() => ({ info, error, refresh }), [info, error, refresh]); + + return ( + {children} + ); +} + +export function useSystemInfo(): Ctx { + const c = useContext(SystemInfoContext); + if (!c) throw new Error("useSystemInfo must be used within SystemInfoProvider"); + return c; +} diff --git a/web/analysis-ui/src/drawOverlay.ts b/web/analysis-ui/src/drawOverlay.ts new file mode 100644 index 0000000..7cbe814 --- /dev/null +++ b/web/analysis-ui/src/drawOverlay.ts @@ -0,0 +1,148 @@ +/** 解算叠加绘制(与旧 debug-analysis 逻辑一致)/ Solve overlay drawing */ + +export type LayerToggles = { + matched: boolean; + pattern: boolean; + all: boolean; +}; + +export type SolveOverlay = { + stars_all_centroids?: Array<{ x: number; y: number }>; + stars_pattern?: Array<{ x: number; y: number }>; + stars_matched?: Array<{ x: number; y: number; mag?: number }>; + overlay_ext?: { + labels_topn?: Array<{ + x: number; + y: number; + name?: string; + mag?: number | null; + ra_deg?: number | null; + dec_deg?: number | null; + }>; + polar_guide?: { + target_kind?: string; + frame_center?: { x: number; y: number }; + target?: { x: number; y: number }; + delta_px?: { dx: number; dy: number }; + angular_sep_deg?: number; + } | null; + }; +}; + +function drawOverlayCore( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + overlay: SolveOverlay | null | undefined, + layers: LayerToggles, +): void { + if (!overlay) return; + ctx.clearRect(0, 0, w, h); + + if (layers.all && Array.isArray(overlay.stars_all_centroids)) { + ctx.fillStyle = "rgba(156, 163, 175, 0.85)"; + for (const s of overlay.stars_all_centroids) { + ctx.beginPath(); + ctx.arc(s.x, s.y, 2.4, 0, Math.PI * 2); + ctx.fill(); + } + } + if (layers.pattern && Array.isArray(overlay.stars_pattern)) { + ctx.strokeStyle = "rgba(251, 146, 60, 0.95)"; + ctx.lineWidth = 2; + for (const s of overlay.stars_pattern) { + ctx.beginPath(); + ctx.arc(s.x, s.y, 6, 0, Math.PI * 2); + ctx.stroke(); + } + } + if (layers.matched && Array.isArray(overlay.stars_matched)) { + ctx.strokeStyle = "rgba(34, 197, 94, 0.95)"; + ctx.fillStyle = "rgba(34, 197, 94, 0.95)"; + ctx.lineWidth = 2; + ctx.font = "11px system-ui, sans-serif"; + for (const s of overlay.stars_matched) { + ctx.beginPath(); + ctx.arc(s.x, s.y, 7, 0, Math.PI * 2); + ctx.stroke(); + if (s.mag != null) { + ctx.fillText(`m${Number(s.mag).toFixed(1)}`, s.x + 4, s.y - 4); + } + } + } + const ext = overlay.overlay_ext; + if (Array.isArray(ext?.labels_topn) && ext.labels_topn.length > 0) { + ctx.fillStyle = "rgba(96, 165, 250, 0.95)"; + ctx.font = "12px system-ui, sans-serif"; + for (const s of ext.labels_topn) { + if (typeof s?.x !== "number" || typeof s?.y !== "number") continue; + const magText = typeof s.mag === "number" ? ` m${s.mag.toFixed(1)}` : ""; + const title = `${s.name ?? "Star"}${magText}`; + ctx.fillText(title, s.x + 8, s.y + 14); + } + } + const guide = ext?.polar_guide; + if ( + guide && + typeof guide.frame_center?.x === "number" && + typeof guide.frame_center?.y === "number" && + typeof guide.target?.x === "number" && + typeof guide.target?.y === "number" + ) { + const cx = guide.frame_center.x; + const cy = guide.frame_center.y; + const tx = guide.target.x; + const ty = guide.target.y; + ctx.strokeStyle = "rgba(244, 63, 94, 0.95)"; + ctx.fillStyle = "rgba(244, 63, 94, 0.95)"; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cx, cy); + ctx.lineTo(tx, ty); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(tx, ty, 6, 0, Math.PI * 2); + ctx.stroke(); + const ang = typeof guide.angular_sep_deg === "number" ? guide.angular_sep_deg.toFixed(2) : "-"; + ctx.font = "12px system-ui, sans-serif"; + ctx.fillText(`Polar ${ang}deg`, tx + 10, ty - 6); + } +} + +export function drawSolveOverlay( + canvas: HTMLCanvasElement, + img: HTMLImageElement, + overlay: SolveOverlay | null | undefined, + layers: LayerToggles, +): void { + if (!overlay) return; + const w = img.naturalWidth || 1; + const h = img.naturalHeight || 1; + canvas.width = w; + canvas.height = h; + canvas.style.width = `${img.clientWidth}px`; + canvas.style.height = `${img.clientHeight}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + drawOverlayCore(ctx, w, h, overlay, layers); +} + +/** 视频当前帧上的叠加(坐标与 videoWidth/Height 一致)/ Overlay on video frame pixels */ +export function drawSolveOverlayVideo( + canvas: HTMLCanvasElement, + video: HTMLVideoElement, + overlay: SolveOverlay | null | undefined, + layers: LayerToggles, +): void { + if (!overlay) return; + const w = video.videoWidth || 1; + const h = video.videoHeight || 1; + if (w < 2 || h < 2) return; + canvas.width = w; + canvas.height = h; + canvas.style.width = `${video.clientWidth}px`; + canvas.style.height = `${video.clientHeight}px`; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + drawOverlayCore(ctx, w, h, overlay, layers); +} diff --git a/web/analysis-ui/src/i18n/I18nProvider.tsx b/web/analysis-ui/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..8808f88 --- /dev/null +++ b/web/analysis-ui/src/i18n/I18nProvider.tsx @@ -0,0 +1,72 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +// 打包进 bundle,避免 fetch /static 失败导致显示原始 key / Bundle JSON to avoid fetch failures +import enDict from "@i18n/analysis.en.json"; +import zhDict from "@i18n/analysis.zh.json"; + +export type Locale = "zh" | "en"; + +type Ctx = { + locale: Locale; + setLocale: (l: Locale) => void; + t: (key: string, vars?: Record) => string; +}; + +const I18nContext = createContext(null); + +const BUNDLED: Record> = { + zh: zhDict as Record, + en: enDict as Record, +}; + +const LOCALE_KEY = "ogscope.analysis.locale"; + +function detectInitialLocale(): Locale { + const saved = window.localStorage.getItem(LOCALE_KEY); + if (saved === "zh" || saved === "en") return saved; + const navLang = (navigator.language || "zh").toLowerCase(); + return navLang.startsWith("en") ? "en" : "zh"; +} + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(detectInitialLocale); + const [dict, setDict] = useState>(BUNDLED[detectInitialLocale()]); + + useEffect(() => { + setDict(BUNDLED[locale]); + window.localStorage.setItem(LOCALE_KEY, locale); + document.documentElement.lang = locale === "en" ? "en" : "zh-CN"; + }, [locale]); + + const t = useMemo( + () => (key: string, vars?: Record) => { + let s = dict[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) { + s = s.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + } + } + return s; + }, + [dict], + ); + + const value = useMemo( + () => ({ locale, setLocale, t }), + [locale, t], + ); + + return {children}; +} + +export function useI18n(): Ctx { + const c = useContext(I18nContext); + if (!c) throw new Error("useI18n must be used within I18nProvider"); + return c; +} diff --git a/web/analysis-ui/src/index.css b/web/analysis-ui/src/index.css new file mode 100644 index 0000000..053471d --- /dev/null +++ b/web/analysis-ui/src/index.css @@ -0,0 +1,37 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body, +#root { + height: 100%; +} + +/* + * 可滚动区域细滚动条(与深色主题一致)/ Thin scrollbar for scroll areas, matches dark theme. + * Firefox: scrollbar-width / scrollbar-color; WebKit: pseudo-elements. + */ +.og-scrollbar { + scrollbar-width: thin; + scrollbar-color: #414754 #191c22; +} + +.og-scrollbar::-webkit-scrollbar { + width: 7px; + height: 7px; +} + +.og-scrollbar::-webkit-scrollbar-track { + background: #191c22; + border-radius: 4px; +} + +.og-scrollbar::-webkit-scrollbar-thumb { + background: #414754; + border-radius: 4px; +} + +.og-scrollbar::-webkit-scrollbar-thumb:hover { + background: #5c6070; +} diff --git a/web/analysis-ui/src/main.tsx b/web/analysis-ui/src/main.tsx new file mode 100644 index 0000000..ff3fff8 --- /dev/null +++ b/web/analysis-ui/src/main.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { I18nProvider } from "./i18n/I18nProvider"; +import "./index.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/web/analysis-ui/src/pages/CameraPage.tsx b/web/analysis-ui/src/pages/CameraPage.tsx new file mode 100644 index 0000000..9bffbd2 --- /dev/null +++ b/web/analysis-ui/src/pages/CameraPage.tsx @@ -0,0 +1,16 @@ +import { useI18n } from "../i18n/I18nProvider"; + +/** 嵌入相机调试页(逻辑在 debug.js)/ Embedded camera debug (logic in debug.js) */ +export function CameraPage() { + const { t } = useI18n(); + return ( +
+

{t("console.camera.hint")}

+