diff --git a/.gitignore b/.gitignore index d42160f..17cccc3 100644 --- a/.gitignore +++ b/.gitignore @@ -92,6 +92,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 # ==================== # 配置文件(敏感信息) diff --git a/data/catalog/README.md b/data/catalog/README.md new file mode 100644 index 0000000..4d36436 --- /dev/null +++ b/data/catalog/README.md @@ -0,0 +1,19 @@ +# 星表数据库说明 / Star Catalog Database Notes + +- 主库文件:`data/catalog/stars.db` +- 数据源(默认):HYG Database v3(CSV) +- 用途:提供极轴解算与调试控制台的星点查询、匹配与维护 + +## 字段说明 / Fields + +- `source_id`: 唯一标识 / Unique identifier +- `ra`, `dec`: 赤经赤纬(度)/ Right ascension and declination in degrees +- `pmra`, `pmdec`: 自行参数 / Proper motion parameters +- `phot_g_mean_mag`: 亮度星等 / Magnitude +- `name_en`, `name_zh`: 英文/中文名称 +- `description_en`, `description_zh`: 英文/中文描述 + +## 维护方式 / Maintenance + +- 可通过 `/api/catalog/*` 与 `/debug/analysis` 执行下载、索引、CRUD。 +- 数据库文件会随 Git 提交与版本发行。 diff --git a/data/catalog/meta/manifest.json b/data/catalog/meta/manifest.json new file mode 100644 index 0000000..0f365d0 --- /dev/null +++ b/data/catalog/meta/manifest.json @@ -0,0 +1,12 @@ +{ + "generated_at": "2026-03-27T14:47:15.117645+00:00", + "source_file": "data/catalog/raw/catalog_raw.csv", + "db_path": "data/catalog/stars.db", + "magnitude_limit": 12.0, + "ra_bin_size_deg": 5.0, + "record_count": 117931, + "bucket_count": 72, + "source_sha256": "d9f69fd86bbf90a4e4d52b4c5c53eacfa6dfc0bfdef85bfd94f095e0bebe4ebd", + "epoch": "JNow(approx)", + "status": "ready" +} \ No newline at end of file diff --git a/data/catalog/stars.db b/data/catalog/stars.db new file mode 100644 index 0000000..34b0c4f Binary files /dev/null and b/data/catalog/stars.db differ diff --git a/ogscope/algorithms/plate_solve/__init__.py b/ogscope/algorithms/plate_solve/__init__.py new file mode 100644 index 0000000..2d9fcfb --- /dev/null +++ b/ogscope/algorithms/plate_solve/__init__.py @@ -0,0 +1,7 @@ +""" +星图解算模块导出 / Plate solving module exports +""" + +from ogscope.algorithms.plate_solve.solver import PlateSolver, SolveResult + +__all__ = ["PlateSolver", "SolveResult"] diff --git a/ogscope/algorithms/plate_solve/solver.py b/ogscope/algorithms/plate_solve/solver.py new file mode 100644 index 0000000..411238c --- /dev/null +++ b/ogscope/algorithms/plate_solve/solver.py @@ -0,0 +1,90 @@ +""" +简化星图解算器 / Simplified plate solver +""" + +from __future__ import annotations + +from dataclasses import dataclass +from math import cos, radians +from typing import Any + +import numpy as np + +from ogscope.algorithms.star_extract import StarPoint +from ogscope.data.catalog.service import catalog_service + + +@dataclass(slots=True) +class SolveResult: + """解算结果 / Solving result""" + + ra_deg: float + dec_deg: float + confidence: float + solve_source: str + matched_catalog_stars: int + detected_stars: int + + def to_dict(self) -> dict[str, Any]: + return { + "ra_deg": self.ra_deg, + "dec_deg": self.dec_deg, + "confidence": self.confidence, + "solve_source": self.solve_source, + "matched_catalog_stars": self.matched_catalog_stars, + "detected_stars": self.detected_stars, + } + + +class PlateSolver: + """基于提示位姿与星表密度的轻量解算 / Lightweight solver with hint and catalog density""" + + def __init__(self, fov_deg: float = 16.0) -> None: + self.fov_deg = fov_deg + + def solve( + self, + stars: list[StarPoint], + frame_shape: tuple[int, ...], + hint_ra_deg: float, + hint_dec_deg: float, + solve_source: str = "full", + ) -> SolveResult: + """解算画面中心坐标 / Solve frame center coordinate""" + if not stars: + return SolveResult( + ra_deg=hint_ra_deg % 360.0, + dec_deg=float(np.clip(hint_dec_deg, -90.0, 90.0)), + confidence=0.0, + solve_source=solve_source, + matched_catalog_stars=0, + detected_stars=0, + ) + + height, width = frame_shape[:2] + points = np.array([[s.x, s.y, s.flux] for s in stars], dtype=np.float64) + flux = np.clip(points[:, 2], 1e-6, None) + centroid_x = float(np.average(points[:, 0], weights=flux)) + centroid_y = float(np.average(points[:, 1], weights=flux)) + dx = centroid_x - (width / 2.0) + dy = centroid_y - (height / 2.0) + + deg_per_pixel = self.fov_deg / max(width, 1) + dec = float(np.clip(hint_dec_deg - (dy * deg_per_pixel), -90.0, 90.0)) + cos_dec = max(0.01, cos(radians(dec))) + ra = (hint_ra_deg + (dx * deg_per_pixel / cos_dec)) % 360.0 + + nearby_catalog = catalog_service.load_records_for_region(ra_deg=ra, search_bins=1) + expected_stars = max(1, len(nearby_catalog)) + detected_stars = len(stars) + matched = min(detected_stars, expected_stars) + confidence = min(1.0, detected_stars / expected_stars) + + return SolveResult( + ra_deg=ra, + dec_deg=dec, + confidence=float(confidence), + solve_source=solve_source, + matched_catalog_stars=matched, + detected_stars=detected_stars, + ) 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..78bd2f8 --- /dev/null +++ b/ogscope/algorithms/star_extract/extractor.py @@ -0,0 +1,65 @@ +""" +星点提取器 / Star extractor +""" + +from __future__ import annotations + +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""" + + 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() + + # 使用高斯模糊降低高频噪声 / Apply gaussian blur for high-frequency noise + blur = cv2.GaussianBlur(gray, (3, 3), 0) + _, binary = cv2.threshold( + blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU + ) + contours, _ = cv2.findContours( + binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE + ) + + points: list[StarPoint] = [] + for contour in contours: + area = float(cv2.contourArea(contour)) + if area <= 0.5: + continue + m = cv2.moments(contour) + if m["m00"] <= 0: + continue + cx = float(m["m10"] / m["m00"]) + cy = float(m["m01"] / m["m00"]) + 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..c6e025a --- /dev/null +++ b/ogscope/algorithms/star_match/tracker.py @@ -0,0 +1,57 @@ +""" +快速跟踪器 / 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 f815b61..b5388b8 100644 --- a/ogscope/config.py +++ b/ogscope/config.py @@ -56,8 +56,21 @@ class Settings(BaseSettings): # 文件路径配置 / 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="分析任务目录" + ) + catalog_dir: Path = Field(default=Path("./data/catalog"), description="星表目录") 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=16.0, description="视场角(度)") + solver_max_stars: int = Field(default=80, description="用于解算的最大星点数量") + solver_fullsolve_interval_frames: int = Field( + default=10, description="实时模式全量解算间隔帧数" + ) model_config = SettingsConfigDict( env_file=".env", @@ -71,6 +84,8 @@ def __init__(self, **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.catalog_dir.mkdir(parents=True, exist_ok=True) @lru_cache() 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..e40a392 --- /dev/null +++ b/ogscope/core/realtime/service.py @@ -0,0 +1,142 @@ +""" +实时解算服务 / 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.algorithms.star_match import FastTracker +from ogscope.config import get_settings +from ogscope.web.api.debug.services import DebugCameraService + + +@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: + """实时解算器 / Realtime solver""" + + 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) + self.tracker = FastTracker() + 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) + + async def start( + self, hint_ra_deg: float | None = None, hint_dec_deg: float | 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.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: + camera = DebugCameraService.get_camera_instance() + if not camera or not getattr(camera, "is_capturing", False): + await asyncio.sleep(0.1) + continue + frame = camera.get_video_frame() + 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 = self.solver.solve( + stars=stars, + frame_shape=frame.shape, + hint_ra_deg=self._hint_ra, + hint_dec_deg=self._hint_dec, + solve_source="full", + ) + self._apply_solve_result(solved) + self.state.fullsolve_count += 1 + else: + track = self.tracker.track(self._previous_stars or [], stars) + deg_per_px = self.solver.fov_deg / max(frame.shape[1], 1) + self._hint_dec = float( + max(-90.0, min(90.0, self._hint_dec - track.delta_y * deg_per_px)) + ) + self._hint_ra = float((self._hint_ra + track.delta_x * deg_per_px) % 360.0) + solved = self.solver.solve( + stars=stars, + frame_shape=frame.shape, + hint_ra_deg=self._hint_ra, + hint_dec_deg=self._hint_dec, + solve_source="track", + ) + base = solved.to_dict() + base["track"] = track.to_dict() + self.state.last_result = base + self._hint_ra = solved.ra_deg + self._hint_dec = solved.dec_deg + 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 _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/data/catalog/__init__.py b/ogscope/data/catalog/__init__.py new file mode 100644 index 0000000..24969d3 --- /dev/null +++ b/ogscope/data/catalog/__init__.py @@ -0,0 +1,7 @@ +""" +星表数据模块导出 / Catalog data module exports +""" + +from ogscope.data.catalog.service import CatalogService, catalog_service + +__all__ = ["CatalogService", "catalog_service"] diff --git a/ogscope/data/catalog/service.py b/ogscope/data/catalog/service.py new file mode 100644 index 0000000..d4aaeae --- /dev/null +++ b/ogscope/data/catalog/service.py @@ -0,0 +1,770 @@ +""" +星表数据服务 / Catalog data service +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import logging +import sqlite3 +import subprocess +from dataclasses import dataclass +from datetime import datetime, timezone +from math import cos, radians +from pathlib import Path +from typing import Any +from urllib.request import urlretrieve + +from ogscope.config import get_settings + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class CatalogRecord: + """星表记录 / Catalog record""" + + source_id: str + ra: float + dec: float + pmra: float + pmdec: float + phot_g_mean_mag: float + name_en: str + name_zh: str + description_en: str + description_zh: str + + @classmethod + def from_row(cls, row: dict[str, str]) -> "CatalogRecord": + return cls( + source_id=row["source_id"], + ra=float(row["ra"]), + dec=float(row["dec"]), + pmra=float(row.get("pmra", 0.0)), + pmdec=float(row.get("pmdec", 0.0)), + phot_g_mean_mag=float(row["phot_g_mean_mag"]), + name_en=row.get("name_en", row["source_id"]), + name_zh=row.get("name_zh", row.get("name_en", row["source_id"])), + description_en=row.get("description_en", ""), + description_zh=row.get("description_zh", ""), + ) + + def to_dict(self) -> dict[str, Any]: + return { + "source_id": self.source_id, + "ra": self.ra, + "dec": self.dec, + "pmra": self.pmra, + "pmdec": self.pmdec, + "phot_g_mean_mag": self.phot_g_mean_mag, + "name_en": self.name_en, + "name_zh": self.name_zh, + "description_en": self.description_en, + "description_zh": self.description_zh, + } + + +class CatalogService: + """星表服务(SQLite 主存储) / Catalog service with SQLite primary storage""" + + RAW_FILE_NAME = "catalog_raw.csv" + MANIFEST_NAME = "manifest.json" + RAW_DIR_NAME = "raw" + META_DIR_NAME = "meta" + DB_FILE_NAME = "stars.db" + HYG_URL = ( + "https://raw.githubusercontent.com/astronexus/HYG-Database/main/" + "hyg/CURRENT/hygdata_v41.csv" + ) + + _COMMON_CN_NAMES: dict[str, str] = { + "sirius": "天狼星", + "canopus": "老人星", + "arcturus": "大角星", + "vega": "织女星", + "capella": "五车二", + "rigel": "参宿七", + "procyon": "南河三", + "betelgeuse": "参宿四", + "achernar": "水委一", + "hadar": "马腹一", + "altair": "牛郎星", + "aldebaran": "毕宿五", + "spica": "角宿一", + "antares": "心宿二", + "pollux": "北河三", + "fomalhaut": "北落师门", + "deneb": "天津四", + "regulus": "轩辕十四", + "castor": "北河二", + "bellatrix": "参宿五", + "mirfak": "天船三", + "alnilam": "参宿二", + "alnair": "鹤一", + "alioth": "玉衡", + "dubhe": "天枢", + "merak": "天璇", + "polaris": "北极星", + } + + _SEED_ROWS: tuple[dict[str, str], ...] = ( + {"source_id": "hip11767", "ra": "37.95456067", "dec": "89.26410897", "pmra": "44.22", "pmdec": "-11.74", "phot_g_mean_mag": "1.97"}, + {"source_id": "hip32349", "ra": "101.28715533", "dec": "-16.71611586", "pmra": "-546.01", "pmdec": "-1223.07", "phot_g_mean_mag": "-1.46"}, + {"source_id": "hip30438", "ra": "95.98787778", "dec": "-52.69571722", "pmra": "19.93", "pmdec": "23.24", "phot_g_mean_mag": "-0.72"}, + {"source_id": "hip69673", "ra": "213.91530029", "dec": "19.18240917", "pmra": "-1093.39", "pmdec": "-1999.85", "phot_g_mean_mag": "0.03"}, + {"source_id": "hip71683", "ra": "219.89972883", "dec": "-60.83514707", "pmra": "-3606.35", "pmdec": "686.92", "phot_g_mean_mag": "-0.27"}, + {"source_id": "hip91262", "ra": "279.23473479", "dec": "38.78368896", "pmra": "200.94", "pmdec": "286.23", "phot_g_mean_mag": "0.03"}, + {"source_id": "hip113368", "ra": "344.41269272", "dec": "-29.62223628", "pmra": "329.95", "pmdec": "-164.67", "phot_g_mean_mag": "1.16"}, + {"source_id": "hip21421", "ra": "68.98016279", "dec": "16.50930235", "pmra": "24.95", "pmdec": "-14.53", "phot_g_mean_mag": "0.85"}, + {"source_id": "hip65474", "ra": "201.29824762", "dec": "-11.16132218", "pmra": "-109.23", "pmdec": "-73.36", "phot_g_mean_mag": "0.98"}, + {"source_id": "hip80763", "ra": "247.35191583", "dec": "-26.43200231", "pmra": "-8.53", "pmdec": "-23.85", "phot_g_mean_mag": "1.06"}, + ) + + def __init__(self) -> None: + settings = get_settings() + self.catalog_dir = settings.catalog_dir + self.raw_dir = self.catalog_dir / self.RAW_DIR_NAME + self.meta_dir = self.catalog_dir / self.META_DIR_NAME + self.db_path = self.catalog_dir / self.DB_FILE_NAME + self._ensure_dirs() + self._init_db() + + @property + def raw_file(self) -> Path: + return self.raw_dir / self.RAW_FILE_NAME + + @property + def manifest_file(self) -> Path: + return self.meta_dir / self.MANIFEST_NAME + + def reconfigure_storage(self, catalog_dir: Path) -> None: + """重新配置存储路径 / Reconfigure storage paths""" + self.catalog_dir = catalog_dir + self.raw_dir = self.catalog_dir / self.RAW_DIR_NAME + self.meta_dir = self.catalog_dir / self.META_DIR_NAME + self.db_path = self.catalog_dir / self.DB_FILE_NAME + self._ensure_dirs() + self._init_db() + + def download_catalog( + self, source: str = "seed", url: str | None = None, magnitude_limit: float = 8.5 + ) -> dict[str, Any]: + """下载或生成星表,并导入数据库 / Download or generate catalog and import to DB""" + if source == "seed": + self._write_seed_catalog(self.raw_file, magnitude_limit=magnitude_limit) + elif source == "hyg": + target_url = url or self.HYG_URL + if target_url.endswith(".gz"): + gz_path = self.raw_dir / "hyg_catalog.csv.gz" + self._download_file(target_url, gz_path) + self._gunzip_file(gz_path, self.raw_file) + else: + self._download_file(target_url, self.raw_file) + elif source == "url": + if not url: + raise ValueError("url source 模式必须提供 URL / URL is required for url source") + self._download_file(url, self.raw_file) + else: + raise ValueError("不支持的 source,允许 seed / hyg / url / Unsupported source") + + imported_count = self._import_csv_to_db(self.raw_file, magnitude_limit, source) + self._set_meta("source", source) + self._set_meta("magnitude_limit", str(magnitude_limit)) + self._set_meta("source_sha256", self._sha256_of_file(self.raw_file)) + self._set_meta("status", "imported") + self._set_meta("last_download_at", datetime.now(timezone.utc).isoformat()) + return { + "success": True, + "source": source, + "path": str(self.raw_file), + "imported_count": imported_count, + "message": "星表已导入数据库 / Catalog imported into SQLite", + } + + def build_index( + self, magnitude_limit: float = 8.5, ra_bin_size_deg: float = 15.0 + ) -> dict[str, Any]: + """构建数据库索引与统计 / Build DB indexes and stats""" + with self._connect() as conn: + conn.execute("CREATE INDEX IF NOT EXISTS idx_stars_ra_now ON stars(ra_now)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_stars_dec_now ON stars(dec_now)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stars_mag ON stars(phot_g_mean_mag)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stars_source_id ON stars(source_id)" + ) + conn.execute("ANALYZE") + + count_row = conn.execute( + "SELECT COUNT(*) AS c FROM stars WHERE phot_g_mean_mag <= ?", + (magnitude_limit,), + ).fetchone() + record_count = int(count_row["c"]) if count_row else 0 + bucket_rows = conn.execute( + "SELECT CAST(ra_now / ? AS INTEGER) AS rb, COUNT(*) AS c " + "FROM stars WHERE phot_g_mean_mag <= ? GROUP BY rb", + (ra_bin_size_deg, magnitude_limit), + ).fetchall() + bucket_count = len(bucket_rows) + + now_iso = datetime.now(timezone.utc).isoformat() + manifest = { + "generated_at": now_iso, + "source_file": str(self.raw_file), + "db_path": str(self.db_path), + "magnitude_limit": magnitude_limit, + "ra_bin_size_deg": ra_bin_size_deg, + "record_count": record_count, + "bucket_count": bucket_count, + "source_sha256": self._meta("source_sha256", ""), + "epoch": "JNow(approx)", + "status": "ready", + } + self.manifest_file.write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + self._set_meta("status", "ready") + self._set_meta("ra_bin_size_deg", str(ra_bin_size_deg)) + self._set_meta("magnitude_limit", str(magnitude_limit)) + self._set_meta("last_build_at", now_iso) + return {"success": True, **manifest} + + def get_status(self) -> dict[str, Any]: + """获取星表状态 / Get catalog status""" + with self._connect() as conn: + row = conn.execute("SELECT COUNT(*) AS c FROM stars").fetchone() + total_count = int(row["c"]) if row else 0 + ready = total_count > 0 and self._meta("status", "") in {"ready", "imported"} + return { + "ready": ready, + "status": self._meta("status", "empty"), + "catalog_dir": str(self.catalog_dir), + "db_path": str(self.db_path), + "source": self._meta("source", ""), + "magnitude_limit": float(self._meta("magnitude_limit", "8.5")), + "ra_bin_size_deg": float(self._meta("ra_bin_size_deg", "15.0")), + "last_download_at": self._meta("last_download_at", ""), + "last_build_at": self._meta("last_build_at", ""), + "record_count": total_count, + } + + def load_records_for_region( + self, ra_deg: float, search_bins: int = 1 + ) -> list[CatalogRecord]: + """按 RA 区域读取星点 / Load stars by RA region""" + if not self.get_status().get("ready"): + return [] + bin_size = float(self._meta("ra_bin_size_deg", "15.0")) + half_width = max(1.0, (search_bins + 1) * bin_size) + ra_center = ra_deg % 360.0 + ra_min = ra_center - half_width + ra_max = ra_center + half_width + + query = ( + "SELECT source_id, ra, dec, pmra, pmdec, phot_g_mean_mag, " + "name_en, name_zh, description_en, description_zh FROM stars " + "WHERE phot_g_mean_mag <= ? AND " + ) + mag_limit = float(self._meta("magnitude_limit", "8.5")) + params: tuple[float, ...] + if ra_min < 0: + query += "(ra_now >= ? OR ra_now <= ?) " + params = (mag_limit, 360.0 + ra_min, ra_max) + elif ra_max >= 360.0: + query += "(ra_now >= ? OR ra_now <= ?) " + params = (mag_limit, ra_min, ra_max - 360.0) + else: + query += "ra_now BETWEEN ? AND ? " + params = (mag_limit, ra_min, ra_max) + query += "ORDER BY phot_g_mean_mag ASC LIMIT 500" + + with self._connect() as conn: + rows = conn.execute(query, params).fetchall() + return [ + CatalogRecord( + source_id=str(row["source_id"]), + ra=float(row["ra"]), + dec=float(row["dec"]), + pmra=float(row["pmra"]), + pmdec=float(row["pmdec"]), + phot_g_mean_mag=float(row["phot_g_mean_mag"]), + name_en=str(row["name_en"]), + name_zh=str(row["name_zh"]), + description_en=str(row["description_en"]), + description_zh=str(row["description_zh"]), + ) + for row in rows + ] + + def list_stars( + self, + limit: int = 100, + offset: int = 0, + source_query: str | None = None, + min_mag: float | None = None, + max_mag: float | None = None, + ) -> dict[str, Any]: + """分页查询星点 / List stars with pagination""" + where: list[str] = [] + params: list[Any] = [] + if source_query: + where.append("source_id LIKE ?") + params.append(f"%{source_query}%") + if min_mag is not None: + where.append("phot_g_mean_mag >= ?") + params.append(min_mag) + if max_mag is not None: + where.append("phot_g_mean_mag <= ?") + params.append(max_mag) + where_clause = f"WHERE {' AND '.join(where)}" if where else "" + sql = ( + "SELECT source_id, ra, dec, pmra, pmdec, phot_g_mean_mag, " + "name_en, name_zh, description_en, description_zh, " + "ra_now, dec_now, updated_at " + f"FROM stars {where_clause} ORDER BY phot_g_mean_mag ASC LIMIT ? OFFSET ?" + ) + count_sql = f"SELECT COUNT(*) AS c FROM stars {where_clause}" + params_with_page = [*params, max(1, limit), max(0, offset)] + with self._connect() as conn: + rows = conn.execute(sql, params_with_page).fetchall() + count_row = conn.execute(count_sql, params).fetchone() + return { + "total": int(count_row["c"]) if count_row else 0, + "items": [dict(row) for row in rows], + } + + def get_star(self, source_id: str) -> dict[str, Any] | None: + """按 source_id 查询星点 / Get star by source_id""" + with self._connect() as conn: + row = conn.execute( + "SELECT source_id, ra, dec, pmra, pmdec, phot_g_mean_mag, " + "name_en, name_zh, description_en, description_zh, " + "ra_now, dec_now, updated_at " + "FROM stars WHERE source_id = ?", + (source_id,), + ).fetchone() + return dict(row) if row else None + + def create_star(self, payload: dict[str, Any]) -> dict[str, Any]: + """新增星点 / Create star""" + record = CatalogRecord( + source_id=str(payload["source_id"]), + ra=float(payload["ra"]), + dec=float(payload["dec"]), + pmra=float(payload.get("pmra", 0.0)), + pmdec=float(payload.get("pmdec", 0.0)), + phot_g_mean_mag=float(payload["phot_g_mean_mag"]), + name_en=str(payload.get("name_en", payload["source_id"])), + name_zh=str(payload.get("name_zh", payload.get("name_en", payload["source_id"]))), + description_en=str(payload.get("description_en", "")), + description_zh=str(payload.get("description_zh", "")), + ) + normalized = self._normalize_record_to_observation_epoch(record) + now_iso = datetime.now(timezone.utc).isoformat() + with self._connect() as conn: + conn.execute( + "INSERT INTO stars (source_id, ra, dec, pmra, pmdec, phot_g_mean_mag, " + "name_en, name_zh, description_en, description_zh, " + "ra_now, dec_now, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ( + normalized.source_id, + normalized.ra, + normalized.dec, + normalized.pmra, + normalized.pmdec, + normalized.phot_g_mean_mag, + normalized.name_en, + normalized.name_zh, + normalized.description_en, + normalized.description_zh, + normalized.ra, + normalized.dec, + now_iso, + now_iso, + ), + ) + result = self.get_star(normalized.source_id) + if not result: + raise ValueError("新增星点失败 / Failed to create star") + return result + + def update_star(self, source_id: str, payload: dict[str, Any]) -> dict[str, Any]: + """更新星点 / Update star""" + existing = self.get_star(source_id) + if not existing: + raise FileNotFoundError("星点不存在 / Star not found") + merged = { + "source_id": source_id, + "ra": payload.get("ra", existing["ra"]), + "dec": payload.get("dec", existing["dec"]), + "pmra": payload.get("pmra", existing["pmra"]), + "pmdec": payload.get("pmdec", existing["pmdec"]), + "phot_g_mean_mag": payload.get( + "phot_g_mean_mag", existing["phot_g_mean_mag"] + ), + "name_en": payload.get("name_en", existing["name_en"]), + "name_zh": payload.get("name_zh", existing["name_zh"]), + "description_en": payload.get("description_en", existing["description_en"]), + "description_zh": payload.get("description_zh", existing["description_zh"]), + } + normalized = self._normalize_record_to_observation_epoch( + CatalogRecord( + source_id=str(merged["source_id"]), + ra=float(merged["ra"]), + dec=float(merged["dec"]), + pmra=float(merged["pmra"]), + pmdec=float(merged["pmdec"]), + phot_g_mean_mag=float(merged["phot_g_mean_mag"]), + name_en=str(merged["name_en"]), + name_zh=str(merged["name_zh"]), + description_en=str(merged["description_en"]), + description_zh=str(merged["description_zh"]), + ) + ) + with self._connect() as conn: + conn.execute( + "UPDATE stars SET ra = ?, dec = ?, pmra = ?, pmdec = ?, phot_g_mean_mag = ?, " + "name_en = ?, name_zh = ?, description_en = ?, description_zh = ?, " + "ra_now = ?, dec_now = ?, updated_at = ? WHERE source_id = ?", + ( + normalized.ra, + normalized.dec, + normalized.pmra, + normalized.pmdec, + normalized.phot_g_mean_mag, + normalized.name_en, + normalized.name_zh, + normalized.description_en, + normalized.description_zh, + normalized.ra, + normalized.dec, + datetime.now(timezone.utc).isoformat(), + source_id, + ), + ) + result = self.get_star(source_id) + if not result: + raise ValueError("更新星点失败 / Failed to update star") + return result + + def delete_star(self, source_id: str) -> bool: + """删除星点 / Delete star""" + with self._connect() as conn: + cursor = conn.execute("DELETE FROM stars WHERE source_id = ?", (source_id,)) + return cursor.rowcount > 0 + + def _ensure_dirs(self) -> None: + self.catalog_dir.mkdir(parents=True, exist_ok=True) + self.raw_dir.mkdir(parents=True, exist_ok=True) + self.meta_dir.mkdir(parents=True, exist_ok=True) + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def _init_db(self) -> None: + try: + self._create_or_migrate_db() + return + except sqlite3.DatabaseError as exc: + if not self._is_malformed_error(exc): + raise + logger.error( + "检测到星表数据库损坏,准备自动恢复 / Corrupted catalog DB detected, starting auto-recovery: %s", + exc, + ) + + self._recover_malformed_db() + self._create_or_migrate_db() + self._set_meta( + "recovered_from_corruption_at", + datetime.now(timezone.utc).isoformat(), + ) + + def _create_or_migrate_db(self) -> None: + with self._connect() as conn: + conn.execute( + "CREATE TABLE IF NOT EXISTS stars (" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "source_id TEXT NOT NULL UNIQUE, " + "ra REAL NOT NULL, " + "dec REAL NOT NULL, " + "pmra REAL NOT NULL DEFAULT 0, " + "pmdec REAL NOT NULL DEFAULT 0, " + "phot_g_mean_mag REAL NOT NULL, " + "name_en TEXT NOT NULL DEFAULT '', " + "name_zh TEXT NOT NULL DEFAULT '', " + "description_en TEXT NOT NULL DEFAULT '', " + "description_zh TEXT NOT NULL DEFAULT '', " + "ra_now REAL NOT NULL, " + "dec_now REAL NOT NULL, " + "created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL" + ")" + ) + conn.execute( + "CREATE TABLE IF NOT EXISTS catalog_meta (" + "key TEXT PRIMARY KEY, " + "value TEXT NOT NULL" + ")" + ) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + self._migrate_schema() + + @staticmethod + def _is_malformed_error(exc: sqlite3.DatabaseError) -> bool: + message = str(exc).lower() + return ( + "malformed" in message + or "disk image is malformed" in message + or "file is not a database" in message + ) + + def _recover_malformed_db(self) -> None: + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + backup_path = self.catalog_dir / f"stars_corrupt_{timestamp}.db" + db_exists = self.db_path.exists() + if db_exists: + try: + self.db_path.replace(backup_path) + except Exception: + try: + self.db_path.unlink() + except FileNotFoundError: + pass + for sidecar in ( + self.db_path.with_suffix(".db-wal"), + self.db_path.with_suffix(".db-shm"), + ): + try: + sidecar.unlink() + except FileNotFoundError: + pass + logger.warning( + "星表数据库已恢复,原损坏文件备份到: %s / Catalog DB recovered, backup saved at: %s", + str(backup_path) if db_exists else "N/A", + str(backup_path) if db_exists else "N/A", + ) + + def _migrate_schema(self) -> None: + """为旧库补齐新字段 / Add missing columns for legacy DB""" + required_columns = { + "name_en": "TEXT NOT NULL DEFAULT ''", + "name_zh": "TEXT NOT NULL DEFAULT ''", + "description_en": "TEXT NOT NULL DEFAULT ''", + "description_zh": "TEXT NOT NULL DEFAULT ''", + } + with self._connect() as conn: + rows = conn.execute("PRAGMA table_info(stars)").fetchall() + existing = {str(row["name"]) for row in rows} + for column_name, column_spec in required_columns.items(): + if column_name in existing: + continue + conn.execute( + f"ALTER TABLE stars ADD COLUMN {column_name} {column_spec}" + ) + + def _set_meta(self, key: str, value: str) -> None: + with self._connect() as conn: + conn.execute( + "INSERT INTO catalog_meta (key, value) VALUES (?, ?) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value", + (key, value), + ) + + def _meta(self, key: str, default: str) -> str: + with self._connect() as conn: + row = conn.execute( + "SELECT value FROM catalog_meta WHERE key = ?", (key,) + ).fetchone() + return str(row["value"]) if row else default + + def _write_seed_catalog(self, target: Path, magnitude_limit: float) -> None: + rows = [] + for row in self._SEED_ROWS: + if float(row["phot_g_mean_mag"]) > magnitude_limit: + continue + enriched = dict(row) + enriched["name_en"] = row["source_id"] + enriched["name_zh"] = row["source_id"] + enriched["description_en"] = "Seed catalog star / 种子星表星点" + enriched["description_zh"] = "种子星表星点 / Seed catalog star" + rows.append(enriched) + with target.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter( + f, + fieldnames=[ + "source_id", + "ra", + "dec", + "pmra", + "pmdec", + "phot_g_mean_mag", + "name_en", + "name_zh", + "description_en", + "description_zh", + ], + ) + writer.writeheader() + writer.writerows(rows) + + def _import_csv_to_db( + self, csv_file: Path, magnitude_limit: float, source: str + ) -> int: + dedup_source_ids: set[str] = set() + now_iso = datetime.now(timezone.utc).isoformat() + imported_count = 0 + with csv_file.open("r", encoding="utf-8") as f, self._connect() as conn: + reader = csv.DictReader(f) + for raw in reader: + try: + record = self._record_from_raw_row(raw, source) + except (KeyError, ValueError): + continue + if record.source_id in dedup_source_ids: + continue + if not (-90.0 <= record.dec <= 90.0 and 0.0 <= record.ra <= 360.0): + continue + if record.phot_g_mean_mag > magnitude_limit: + continue + normalized = self._normalize_record_to_observation_epoch(record) + conn.execute( + "INSERT INTO stars (source_id, ra, dec, pmra, pmdec, phot_g_mean_mag, " + "name_en, name_zh, description_en, description_zh, " + "ra_now, dec_now, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(source_id) DO UPDATE SET " + "ra = excluded.ra, dec = excluded.dec, pmra = excluded.pmra, pmdec = excluded.pmdec, " + "phot_g_mean_mag = excluded.phot_g_mean_mag, " + "name_en = excluded.name_en, name_zh = excluded.name_zh, " + "description_en = excluded.description_en, description_zh = excluded.description_zh, " + "ra_now = excluded.ra_now, dec_now = excluded.dec_now, updated_at = excluded.updated_at", + ( + normalized.source_id, + normalized.ra, + normalized.dec, + normalized.pmra, + normalized.pmdec, + normalized.phot_g_mean_mag, + normalized.name_en, + normalized.name_zh, + normalized.description_en, + normalized.description_zh, + normalized.ra, + normalized.dec, + now_iso, + now_iso, + ), + ) + dedup_source_ids.add(record.source_id) + imported_count += 1 + return imported_count + + def _normalize_record_to_observation_epoch(self, record: CatalogRecord) -> CatalogRecord: + now_year = datetime.now(timezone.utc).year + years = max(0.0, float(now_year - 2016)) + dec_offset_deg = (record.pmdec * years) / 3_600_000.0 + corrected_dec = max(-90.0, min(90.0, record.dec + dec_offset_deg)) + cos_dec = max(0.01, cos(radians(corrected_dec))) + ra_offset_deg = (record.pmra * years) / 3_600_000.0 / cos_dec + corrected_ra = (record.ra + ra_offset_deg) % 360.0 + return CatalogRecord( + source_id=record.source_id, + ra=corrected_ra, + dec=corrected_dec, + pmra=record.pmra, + pmdec=record.pmdec, + phot_g_mean_mag=record.phot_g_mean_mag, + name_en=record.name_en, + name_zh=record.name_zh, + description_en=record.description_en, + description_zh=record.description_zh, + ) + + def _record_from_raw_row(self, raw: dict[str, str], source: str) -> CatalogRecord: + """将来源行转为统一记录 / Convert source row to common record""" + if source == "hyg": + return self._record_from_hyg_row(raw) + return CatalogRecord.from_row(raw) + + def _record_from_hyg_row(self, raw: dict[str, str]) -> CatalogRecord: + """解析 HYG 行 / Parse HYG row""" + raw_id = raw.get("id") or raw.get("hip") or raw.get("hd") or raw.get("hr") + if not raw_id: + raise ValueError("missing id") + source_id = f"hyg_{str(raw_id).strip()}" + ra_hours = float(raw.get("ra", "0") or 0.0) + ra_deg = (ra_hours * 15.0) % 360.0 + dec_deg = float(raw.get("dec", "0") or 0.0) + pmra = float(raw.get("pmra", "0") or 0.0) + pmdec = float(raw.get("pmdec", "0") or 0.0) + mag = float(raw.get("mag", "99") or 99.0) + proper_name = (raw.get("proper") or "").strip() + bayer_name = (raw.get("bf") or "").strip() + name_en = proper_name or bayer_name or source_id + name_zh = self._COMMON_CN_NAMES.get(name_en.lower(), name_en) + constellation = (raw.get("con") or "").strip() + description_en = ( + f"Star {name_en}; mag={mag:.2f}; constellation={constellation or 'unknown'}." + ) + description_zh = ( + f"恒星{name_zh};星等={mag:.2f};星座={constellation or '未知'}。" + ) + return CatalogRecord( + source_id=source_id, + ra=ra_deg, + dec=dec_deg, + pmra=pmra, + pmdec=pmdec, + phot_g_mean_mag=mag, + name_en=name_en, + name_zh=name_zh, + description_en=description_en, + description_zh=description_zh, + ) + + def _download_file(self, url: str, target: Path) -> None: + """下载文件,支持 urllib/curl 回退 / Download file with urllib/curl fallback""" + try: + urlretrieve(url, target) # noqa: S310 - controlled by API input + return + except Exception: + pass + result = subprocess.run( + ["curl", "-L", "--fail", "-o", str(target), url], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"下载失败 / Download failed: {result.stderr.strip() or result.stdout.strip()}" + ) + + @staticmethod + def _gunzip_file(src: Path, dst: Path) -> None: + """解压 gzip 文件 / Decompress gzip file""" + import gzip + import shutil + + with gzip.open(src, "rb") as reader, dst.open("wb") as writer: + shutil.copyfileobj(reader, writer) + + @staticmethod + def _sha256_of_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + digest.update(chunk) + return digest.hexdigest() + + +catalog_service = CatalogService() diff --git a/ogscope/hardware/camera.py b/ogscope/hardware/camera.py index 8407329..533b304 100644 --- a/ogscope/hardware/camera.py +++ b/ogscope/hardware/camera.py @@ -76,6 +76,9 @@ def __init__(self, config: Dict[str, Any]): 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', 'supersample') # supersample | native | crop ( @@ -612,6 +615,9 @@ def get_camera_info(self) -> Dict[str, Any]: "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, } except Exception as e: logger.error(f"获取相机信息失败: {e}") @@ -710,12 +716,16 @@ def set_white_balance(self, mode: str, gain_r: float = 1.0, gain_b: float = 1.0) 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 @@ -723,6 +733,9 @@ def set_white_balance(self, mode: str, gain_r: float = 1.0, gain_b: float = 1.0) "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}") 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/routes.py b/ogscope/web/api/analysis/routes.py new file mode 100644 index 0000000..55c7176 --- /dev/null +++ b/ogscope/web/api/analysis/routes.py @@ -0,0 +1,75 @@ +""" +素材分析路由 / Asset analysis routes +""" + +from fastapi import APIRouter, File, HTTPException, UploadFile +from fastapi import Query + +from ogscope.web.api.analysis.services import analysis_service +from ogscope.web.api.models.schemas import AnalysisJobCreateRequest + +router = APIRouter() + + +@router.post("/analysis/upload") +async def upload_analysis_asset(file: UploadFile = File(...)): + """上传素材 / Upload asset""" + try: + payload = await file.read() + return await analysis_service.save_upload( + filename=file.filename or "uploaded.bin", + payload=payload, + ) + 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, + ) + 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( + input_name: str = Query(...), + hint_ra_deg: float | None = Query(default=None), + hint_dec_deg: float | None = Query(default=None), +): + """直接解算单图 / Solve single image directly""" + try: + return await analysis_service.solve_single_image( + input_name=input_name, + hint_ra_deg=hint_ra_deg, + hint_dec_deg=hint_dec_deg, + ) + 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..95d03ec --- /dev/null +++ b/ogscope/web/api/analysis/services.py @@ -0,0 +1,255 @@ +""" +素材分析服务 / Asset analysis services +""" + +from __future__ import annotations + +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import cv2 + +from ogscope.algorithms.plate_solve import PlateSolver +from ogscope.algorithms.star_extract import StarExtractor +from ogscope.config import get_settings + + +@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, + } + + +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) + self.extractor = StarExtractor(max_stars=settings.solver_max_stars) + self.solver = PlateSolver(fov_deg=settings.solver_fov_deg) + self.default_hint_ra = settings.solver_hint_ra_deg + self.default_hint_dec = settings.solver_hint_dec_deg + self._jobs: dict[str, AnalysisJob] = {} + + async def save_upload(self, filename: str, payload: bytes) -> 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) + 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, + ) -> 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) + + try: + job.status = "running" + job.message = "开始分析 / Analysis started" + self._persist_job(job) + if input_type == "image": + results = self._analyze_image( + source=source, + hint_ra_deg=hint_ra_deg, + hint_dec_deg=hint_dec_deg, + ) + else: + results = self._analyze_video( + source=source, + hint_ra_deg=hint_ra_deg, + hint_dec_deg=hint_dec_deg, + frame_step=frame_step, + max_frames=max_frames, + job=job, + ) + 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, input_name: str, hint_ra_deg: float | None = None, hint_dec_deg: float | None = None + ) -> dict[str, Any]: + """直接解算单图 / Solve a single image directly""" + source = self.upload_root / Path(input_name).name + if not source.exists(): + raise FileNotFoundError("上传文件不存在 / Uploaded file not found") + rows = self._analyze_image( + source=source, + hint_ra_deg=hint_ra_deg, + hint_dec_deg=hint_dec_deg, + ) + return { + "success": True, + "input_name": source.name, + "result": rows[0] if rows else None, + } + + 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 _analyze_image( + self, source: Path, hint_ra_deg: float | None, hint_dec_deg: float | None + ) -> list[dict[str, Any]]: + """分析单图 / Analyze image""" + frame = cv2.imread(str(source), cv2.IMREAD_COLOR) + if frame is None: + raise ValueError("无法读取图片 / Unable to read image") + stars = self.extractor.extract(frame) + solved = self.solver.solve( + stars=stars, + frame_shape=frame.shape, + 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", + ) + row = {"frame_index": 0, **solved.to_dict()} + 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, + ) -> 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", + ) + 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 + + +analysis_service = AnalysisService() diff --git a/ogscope/web/api/catalog/__init__.py b/ogscope/web/api/catalog/__init__.py new file mode 100644 index 0000000..266ede0 --- /dev/null +++ b/ogscope/web/api/catalog/__init__.py @@ -0,0 +1,3 @@ +""" +星表 API 包 / Catalog API package +""" diff --git a/ogscope/web/api/catalog/routes.py b/ogscope/web/api/catalog/routes.py new file mode 100644 index 0000000..f048400 --- /dev/null +++ b/ogscope/web/api/catalog/routes.py @@ -0,0 +1,113 @@ +""" +星表管理路由 / Catalog management routes +""" + +from fastapi import APIRouter, HTTPException, Query + +from ogscope.web.api.catalog.services import CatalogApiService +from ogscope.web.api.models.schemas import ( + CatalogBuildIndexRequest, + CatalogDownloadRequest, + CatalogStarUpsertRequest, +) + +router = APIRouter() + + +@router.post("/catalog/download") +async def download_catalog(payload: CatalogDownloadRequest): + """下载星表 / Download catalog""" + try: + return await CatalogApiService.download_catalog( + source=payload.source, + url=payload.url, + magnitude_limit=payload.magnitude_limit, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.post("/catalog/build-index") +async def build_catalog_index(payload: CatalogBuildIndexRequest): + """构建索引 / Build index""" + try: + return await CatalogApiService.build_index( + magnitude_limit=payload.magnitude_limit, + ra_bin_size_deg=payload.ra_bin_size_deg, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/catalog/status") +async def get_catalog_status(): + """获取状态 / Get status""" + try: + return await CatalogApiService.get_status() + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@router.get("/catalog/stars") +async def list_catalog_stars( + limit: int = Query(default=100, ge=1, le=2000), + offset: int = Query(default=0, ge=0), + source_query: str | None = Query(default=None), + min_mag: float | None = Query(default=None), + max_mag: float | None = Query(default=None), +): + """分页查询星点 / List stars""" + try: + return await CatalogApiService.list_stars( + limit=limit, + offset=offset, + source_query=source_query, + min_mag=min_mag, + max_mag=max_mag, + ) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/catalog/stars/{source_id}") +async def get_catalog_star(source_id: str): + """读取星点详情 / Get star details""" + try: + return await CatalogApiService.get_star(source_id) + 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("/catalog/stars") +async def create_catalog_star(payload: CatalogStarUpsertRequest): + """新增星点 / Create star""" + try: + return await CatalogApiService.create_star(payload.model_dump()) + except Exception as exc: # noqa: BLE001 + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.put("/catalog/stars/{source_id}") +async def update_catalog_star(source_id: str, payload: CatalogStarUpsertRequest): + """更新星点 / Update star""" + try: + update_payload = payload.model_dump() + update_payload["source_id"] = source_id + return await CatalogApiService.update_star(source_id, update_payload) + 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.delete("/catalog/stars/{source_id}") +async def delete_catalog_star(source_id: str): + """删除星点 / Delete star""" + try: + return await CatalogApiService.delete_star(source_id) + 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 diff --git a/ogscope/web/api/catalog/services.py b/ogscope/web/api/catalog/services.py new file mode 100644 index 0000000..86f94df --- /dev/null +++ b/ogscope/web/api/catalog/services.py @@ -0,0 +1,71 @@ +""" +星表管理服务 / Catalog management services +""" + +from __future__ import annotations + +from typing import Any + +from ogscope.data.catalog.service import catalog_service + + +class CatalogApiService: + """星表 API 服务 / Catalog API service""" + + @staticmethod + async def download_catalog( + source: str, url: str | None, magnitude_limit: float + ) -> dict[str, Any]: + return catalog_service.download_catalog( + source=source, url=url, magnitude_limit=magnitude_limit + ) + + @staticmethod + async def build_index( + magnitude_limit: float, ra_bin_size_deg: float + ) -> dict[str, Any]: + return catalog_service.build_index( + magnitude_limit=magnitude_limit, ra_bin_size_deg=ra_bin_size_deg + ) + + @staticmethod + async def get_status() -> dict[str, Any]: + return catalog_service.get_status() + + @staticmethod + async def list_stars( + limit: int, + offset: int, + source_query: str | None, + min_mag: float | None, + max_mag: float | None, + ) -> dict[str, Any]: + return catalog_service.list_stars( + limit=limit, + offset=offset, + source_query=source_query, + min_mag=min_mag, + max_mag=max_mag, + ) + + @staticmethod + async def get_star(source_id: str) -> dict[str, Any]: + row = catalog_service.get_star(source_id) + if not row: + raise FileNotFoundError("星点不存在 / Star not found") + return row + + @staticmethod + async def create_star(payload: dict[str, Any]) -> dict[str, Any]: + return catalog_service.create_star(payload) + + @staticmethod + async def update_star(source_id: str, payload: dict[str, Any]) -> dict[str, Any]: + return catalog_service.update_star(source_id, payload) + + @staticmethod + async def delete_star(source_id: str) -> dict[str, Any]: + deleted = catalog_service.delete_star(source_id) + if not deleted: + raise FileNotFoundError("星点不存在 / Star not found") + return {"success": True, "source_id": source_id} diff --git a/ogscope/web/api/debug/routes.py b/ogscope/web/api/debug/routes.py index 963b79a..284cc8b 100644 --- a/ogscope/web/api/debug/routes.py +++ b/ogscope/web/api/debug/routes.py @@ -10,6 +10,7 @@ DebugPresetService, DebugFileService ) +from ogscope.core.realtime import realtime_solve_service router = APIRouter() @@ -201,6 +202,15 @@ async def update_debug_camera_settings(settings: CameraSettings): 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""" @@ -264,6 +274,19 @@ async def set_camera_color_mode(color_mode: str = Query(..., pattern="^(color|mo 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 ==================== @@ -348,3 +371,38 @@ async def delete_capture_file(filename: str): return await DebugFileService.delete_file(filename) 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), +): + """启动实时解算 / Start realtime solving""" + try: + return await realtime_solve_service.start( + hint_ra_deg=hint_ra_deg, hint_dec_deg=hint_dec_deg + ) + 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 fdfc730..34deb69 100644 --- a/ogscope/web/api/debug/services.py +++ b/ogscope/web/api/debug/services.py @@ -560,7 +560,26 @@ async def _preview_grabber_loop(): # 记录其他异常 / Log other exceptions import logging logging.getLogger(__name__).error(f"预览抓取器异常: {e}") - + + @staticmethod + async def set_auto_exposure_mode(enabled: bool): + """仅切换自动曝光模式 / Toggle auto-exposure mode only""" + camera = get_camera_instance() + 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("设置自动曝光模式失败") + + return { + "success": True, + **i18n_payload("server.autoExposureUpdated", "曝光模式已更新"), + "auto_exposure": bool(enabled), + } + @staticmethod async def update_settings(settings: Dict[str, Any]): """更新调试相机设置 / Update debug camera settings""" diff --git a/ogscope/web/api/main.py b/ogscope/web/api/main.py index d1291f2..79be18e 100644 --- a/ogscope/web/api/main.py +++ b/ogscope/web/api/main.py @@ -7,6 +7,8 @@ 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.debug.routes import router as debug_router +from ogscope.web.api.analysis.routes import router as analysis_router +from ogscope.web.api.catalog.routes import router as catalog_router # 创建主路由器 / Create the main router router = APIRouter() @@ -16,5 +18,7 @@ router.include_router(alignment_router, tags=["Alignment - 极轴校准"]) router.include_router(system_router, tags=["System - 系统"]) router.include_router(debug_router, tags=["Debug - 调试"]) +router.include_router(analysis_router, tags=["Analysis - 分析"]) +router.include_router(catalog_router, tags=["Catalog - 星表"]) diff --git a/ogscope/web/api/models/schemas.py b/ogscope/web/api/models/schemas.py index 7542245..fa1fae9 100644 --- a/ogscope/web/api/models/schemas.py +++ b/ogscope/web/api/models/schemas.py @@ -79,3 +79,64 @@ class AlignmentStatus(BaseModel): altitude_error: float precision: str progress: int + + +class CatalogDownloadRequest(BaseModel): + """星表下载请求 / Catalog download request""" + + source: str = "seed" + url: Optional[str] = None + magnitude_limit: float = 8.5 + + +class CatalogBuildIndexRequest(BaseModel): + """星表索引构建请求 / Catalog build index request""" + + magnitude_limit: float = 8.5 + ra_bin_size_deg: float = 15.0 + + +class CatalogStarUpsertRequest(BaseModel): + """星点新增/更新请求 / Catalog star upsert request""" + + source_id: str + ra: float + dec: float + pmra: float = 0.0 + pmdec: float = 0.0 + phot_g_mean_mag: float + name_en: Optional[str] = None + name_zh: Optional[str] = None + description_en: Optional[str] = None + description_zh: Optional[str] = None + + +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 + + +class SolveFrameResult(BaseModel): + """单帧解算结果 / Single frame solving result""" + + frame_index: int + ra_deg: float + dec_deg: float + confidence: float + solve_source: str + + +class AnalysisJobStatusResponse(BaseModel): + """分析任务状态响应 / Analysis job status response""" + + job_id: str + status: str + progress: float + message: str = "" + result_path: Optional[str] = None diff --git a/ogscope/web/app.py b/ogscope/web/app.py index 430e32b..0f1dc75 100644 --- a/ogscope/web/app.py +++ b/ogscope/web/app.py @@ -2,6 +2,7 @@ FastAPI Web 应用 """ from contextlib import asynccontextmanager +from pathlib import Path from typing import AsyncGenerator from fastapi import FastAPI, Request @@ -53,6 +54,14 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: "name": "Debug - 调试", "description": "调试控制台接口 / Debug console endpoints", }, + { + "name": "Analysis - 分析", + "description": "素材分析与任务管理 / Asset analysis and job management", + }, + { + "name": "Catalog - 星表", + "description": "星表下载、索引与状态 / Catalog download, indexing, and status", + }, ] # 创建 FastAPI 应用(禁用默认 ReDoc,使用自定义稳定版本) @@ -70,6 +79,13 @@ async def lifespan(app: FastAPI) -> AsyncGenerator: settings = get_settings() templates = Jinja2Templates(directory=str(settings.template_dir)) + +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, @@ -108,15 +124,30 @@ async def root(request: Request): @app.get("/debug", response_class=HTMLResponse) async def debug_console(request: Request): """调试控制台页面 / Debug console page""" + debug_js_path = settings.static_dir / "js" / "debug.js" return templates.TemplateResponse( "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): + """星图解算调试页面 / Plate solve debug page""" + return templates.TemplateResponse( + "debug_analysis.html", + { + "request": request, + "version": __version__, + "app_name": "OGScope Plate Solve Debug Console", + }, + ) + @app.get("/api") async def api_root(): """API根路径 / API root path""" @@ -128,7 +159,9 @@ async def api_root(): "endpoints": { "camera": "/api/camera/", "alignment": "/api/alignment/", - "system": "/api/system/" + "system": "/api/system/", + "analysis": "/api/analysis/", + "catalog": "/api/catalog/", } } diff --git a/tests/conftest.py b/tests/conftest.py index 1f71d4d..666d3f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,3 +33,33 @@ def temp_debug_dir(monkeypatch, tmp_path: Path): return debug_root + +@pytest.fixture +def temp_catalog_dir(tmp_path: Path): + """重定向星表目录到临时路径 / Redirect catalog directory to temp path.""" + from ogscope.data.catalog.service import catalog_service + + catalog_root = tmp_path / "catalog" + catalog_service.reconfigure_storage(catalog_root) + return catalog_root + + +@pytest.fixture +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 = {} + 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..efe380a --- /dev/null +++ b/tests/integration/test_analysis_pipeline.py @@ -0,0 +1,54 @@ +""" +分析管线集成测试 / 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_catalog_and_image_analysis( + client, temp_catalog_dir, temp_analysis_dir, tmp_path: Path +): + """验证星表到单图解算全链路 / Validate end-to-end catalog to single-image solving.""" + resp_download = client.post( + "/api/catalog/download", + json={"source": "seed", "magnitude_limit": 8.5}, + ) + assert resp_download.status_code == 200 + + resp_index = client.post( + "/api/catalog/build-index", + json={"magnitude_limit": 8.5, "ra_bin_size_deg": 15.0}, + ) + assert resp_index.status_code == 200 + assert resp_index.json()["status"] == "ready" + + 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", + params={"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"] in {"full", "track"} diff --git a/tests/unit/test_analysis_api.py b/tests/unit/test_analysis_api.py new file mode 100644 index 0000000..7e9ff94 --- /dev/null +++ b/tests/unit/test_analysis_api.py @@ -0,0 +1,104 @@ +""" +分析 API 测试 / Analysis API tests +""" + +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, temp_catalog_dir, tmp_path: Path): + """测试上传与单图解算 / Test upload and single-image solve.""" + client.post("/api/catalog/download", json={"source": "seed"}) + client.post("/api/catalog/build-index", json={"magnitude_limit": 8.5}) + + 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" + + solve_resp = client.post( + "/api/analysis/solve/image", + params={"input_name": "stars.jpg", "hint_ra_deg": 45.0, "hint_dec_deg": 70.0}, + ) + 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 "confidence" in result + + +@pytest.mark.unit +def test_analysis_video_job(client, temp_analysis_dir, temp_catalog_dir, tmp_path: Path): + """测试视频任务分析 / Test video job analysis.""" + client.post("/api/catalog/download", json={"source": "seed"}) + client.post("/api/catalog/build-index", json={"magnitude_limit": 8.5}) + + 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 diff --git a/tests/unit/test_api.py b/tests/unit/test_api.py index 7e64b16..b751483 100644 --- a/tests/unit/test_api.py +++ b/tests/unit/test_api.py @@ -13,6 +13,15 @@ def test_root(client): assert "OGScope" in response.text +@pytest.mark.unit +def test_debug_analysis_page(client): + """测试星图解算调试页面。 / Test plate solve debug 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.""" diff --git a/tests/unit/test_catalog_api.py b/tests/unit/test_catalog_api.py new file mode 100644 index 0000000..5d9e258 --- /dev/null +++ b/tests/unit/test_catalog_api.py @@ -0,0 +1,111 @@ +""" +星表 API 测试 / Catalog API tests +""" + +from pathlib import Path + +import pytest + + +@pytest.mark.unit +def test_catalog_download_build_and_status(client, temp_catalog_dir): + """测试星表下载、建索引和状态查询 / Test catalog download, build index and status.""" + download_resp = client.post( + "/api/catalog/download", + json={"source": "seed", "magnitude_limit": 8.5}, + ) + assert download_resp.status_code == 200 + download_data = download_resp.json() + assert download_data["success"] is True + assert "path" in download_data + + build_resp = client.post( + "/api/catalog/build-index", + json={"magnitude_limit": 8.5, "ra_bin_size_deg": 30.0}, + ) + assert build_resp.status_code == 200 + build_data = build_resp.json() + assert build_data["success"] is True + assert build_data["record_count"] > 0 + assert build_data["bucket_count"] > 0 + + status_resp = client.get("/api/catalog/status") + assert status_resp.status_code == 200 + status_data = status_resp.json() + assert status_data["ready"] is True + assert status_data["status"] == "ready" + + +@pytest.mark.unit +def test_catalog_star_crud(client, temp_catalog_dir): + """测试星点 CRUD 接口 / Test catalog star CRUD APIs.""" + create_resp = client.post( + "/api/catalog/stars", + json={ + "source_id": "custom_star_001", + "ra": 123.4, + "dec": 45.6, + "pmra": 0.3, + "pmdec": -0.2, + "phot_g_mean_mag": 6.7, + "name_en": "Custom Star", + "name_zh": "自定义恒星", + "description_en": "Custom star for test", + "description_zh": "测试用自定义恒星", + }, + ) + assert create_resp.status_code == 200 + create_data = create_resp.json() + assert create_data["source_id"] == "custom_star_001" + assert create_data["name_zh"] == "自定义恒星" + + get_resp = client.get("/api/catalog/stars/custom_star_001") + assert get_resp.status_code == 200 + assert get_resp.json()["phot_g_mean_mag"] == 6.7 + + list_resp = client.get("/api/catalog/stars", params={"source_query": "custom_star"}) + assert list_resp.status_code == 200 + list_data = list_resp.json() + assert list_data["total"] >= 1 + + update_resp = client.put( + "/api/catalog/stars/custom_star_001", + json={ + "source_id": "custom_star_001", + "ra": 124.5, + "dec": 44.4, + "pmra": 0.4, + "pmdec": -0.1, + "phot_g_mean_mag": 5.8, + "name_en": "Custom Star Updated", + "name_zh": "自定义恒星更新", + "description_en": "Updated custom star", + "description_zh": "更新后的自定义恒星", + }, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["phot_g_mean_mag"] == 5.8 + assert update_resp.json()["name_en"] == "Custom Star Updated" + + delete_resp = client.delete("/api/catalog/stars/custom_star_001") + assert delete_resp.status_code == 200 + assert delete_resp.json()["success"] is True + + +@pytest.mark.unit +def test_catalog_db_auto_recovery_on_malformed(tmp_path: Path): + """测试损坏数据库自动恢复 / Test auto recovery when SQLite is malformed.""" + from ogscope.data.catalog.service import CatalogService + + broken_catalog_dir = tmp_path / "broken_catalog" + broken_catalog_dir.mkdir(parents=True, exist_ok=True) + db_path = broken_catalog_dir / "stars.db" + db_path.write_bytes(b"this is not a sqlite database") + + service = CatalogService() + service.reconfigure_storage(broken_catalog_dir) + status = service.get_status() + + assert Path(status["db_path"]).exists() + backups = list(broken_catalog_dir.glob("stars_corrupt_*.db")) + assert backups, "应当生成损坏数据库备份 / Corrupted DB backup should be created" diff --git a/tests/unit/test_debug_camera_api.py b/tests/unit/test_debug_camera_api.py index af8954d..f51d757 100644 --- a/tests/unit/test_debug_camera_api.py +++ b/tests/unit/test_debug_camera_api.py @@ -18,6 +18,7 @@ def __init__(self): 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 @@ -34,6 +35,7 @@ def get_camera_info(self): "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, @@ -91,6 +93,7 @@ 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): @@ -213,3 +216,25 @@ def test_debug_camera_update_settings_success(client, fake_camera_env): 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_realtime_api.py b/tests/unit/test_realtime_api.py new file mode 100644 index 0000000..80ef464 --- /dev/null +++ b/tests/unit/test_realtime_api.py @@ -0,0 +1,54 @@ +""" +实时解算 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): + """测试实时解算启停接口 / Test realtime solver start and stop endpoints.""" + from ogscope.web.api.debug import routes as debug_routes + + fake_camera = _FakeCamera() + monkeypatch.setattr( + debug_routes.DebugCameraService, + "get_camera_instance", + staticmethod(lambda: fake_camera), + ) + + 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..4d8fb9e --- /dev/null +++ b/tests/unit/test_solver_performance_baseline.py @@ -0,0 +1,53 @@ +""" +解算性能基线测试 / Solver performance baseline tests +""" + +from __future__ import annotations + +import time + +import cv2 +import numpy as np +import pytest + +from ogscope.algorithms.plate_solve import PlateSolver +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_extract_and_solve_performance_baseline(): + """校验基础性能阈值 / Validate baseline performance threshold.""" + extractor = StarExtractor(max_stars=80) + solver = PlateSolver(fov_deg=16.0) + frame = _synthetic_frame() + rounds = 40 + + start = time.perf_counter() + for _ in range(rounds): + stars = extractor.extract(frame) + solved = solver.solve( + stars=stars, + frame_shape=frame.shape, + hint_ra_deg=12.0, + hint_dec_deg=86.0, + solve_source="full", + ) + assert 0.0 <= solved.ra_deg <= 360.0 + assert -90.0 <= solved.dec_deg <= 90.0 + elapsed = time.perf_counter() - start + avg_ms = (elapsed / rounds) * 1000.0 + + # Pi Zero 上阈值会更高,这里以开发机回归检测为主 / Threshold is higher on Pi Zero; here we use dev-machine regression guard + assert avg_ms < 35.0 diff --git a/web/static/css/debug-analysis.css b/web/static/css/debug-analysis.css new file mode 100644 index 0000000..ee81488 --- /dev/null +++ b/web/static/css/debug-analysis.css @@ -0,0 +1,113 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #111827; + color: #e5e7eb; +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 16px; +} + +.topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} + +.topbar h1 { + margin: 0; + font-size: 1.4rem; +} + +.topbar-actions { + display: flex; + gap: 8px; +} + +.main-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} + +.card { + background: #1f2937; + border: 1px solid #374151; + border-radius: 12px; + padding: 14px; +} + +.card h2 { + margin-top: 0; + font-size: 1.1rem; +} + +.row { + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +input, +select { + width: 100%; + box-sizing: border-box; + border: 1px solid #4b5563; + border-radius: 8px; + background: #111827; + color: #e5e7eb; + padding: 8px 10px; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 12px; +} + +.btn { + border: 1px solid #4b5563; + background: #374151; + color: #e5e7eb; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; +} + +.btn.primary { + background: #2563eb; + border-color: #2563eb; +} + +.btn.danger { + background: #dc2626; + border-color: #dc2626; +} + +.inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.9rem; +} + +.output { + margin: 0; + min-height: 140px; + max-height: 320px; + overflow: auto; + background: #0b1220; + border: 1px solid #374151; + border-radius: 8px; + padding: 10px; + font-size: 12px; + line-height: 1.45; +} diff --git a/web/static/i18n/debug.en.json b/web/static/i18n/debug.en.json index f8eeaee..c406b9b 100644 --- a/web/static/i18n/debug.en.json +++ b/web/static/i18n/debug.en.json @@ -89,6 +89,10 @@ "settings.exposureModeAuto": "Auto Exposure (Recommended)", "settings.exposureModeManual": "Manual Exposure", "settings.exposureModeHint": "Auto exposure is more stable in changing light; smart adjustment is for manual mode only", + "settings.currentApplied": "Current", + "settings.applyChange": "Apply Change", + "settings.pendingSync": "Pending value matches current applied value", + "settings.pendingUnsynced": "There are unapplied changes", "settings.noiseReduction": "Noise Reduction", "settings.noiseReductionHint": "Range: 0-4 | 0=off, 4=strongest", "settings.colorMode": "Color Mode", diff --git a/web/static/i18n/debug.zh.json b/web/static/i18n/debug.zh.json index 4a54d5d..dd06012 100644 --- a/web/static/i18n/debug.zh.json +++ b/web/static/i18n/debug.zh.json @@ -89,6 +89,10 @@ "settings.exposureModeAuto": "自动曝光 (推荐)", "settings.exposureModeManual": "手动曝光", "settings.exposureModeHint": "自动曝光在动态光线下更稳定;智能调整仅用于手动模式", + "settings.currentApplied": "当前生效", + "settings.applyChange": "应用修改", + "settings.pendingSync": "待修改值与当前生效值一致", + "settings.pendingUnsynced": "存在未应用修改", "settings.noiseReduction": "降噪级别", "settings.noiseReductionHint": "范围: 0-4级 | 0=关闭, 4=最强", "settings.colorMode": "颜色模式", diff --git a/web/static/js/debug-analysis.js b/web/static/js/debug-analysis.js new file mode 100644 index 0000000..375a053 --- /dev/null +++ b/web/static/js/debug-analysis.js @@ -0,0 +1,261 @@ +(function () { + const state = { + uploadedFileName: null, + lastJobId: null, + pollTimer: null, + }; + + function $(id) { + return document.getElementById(id); + } + + function setOutput(id, payload) { + const node = $(id); + if (!node) return; + node.textContent = + typeof payload === "string" ? payload : JSON.stringify(payload, null, 2); + } + + async function request(url, options = {}) { + const resp = await fetch(url, options); + const contentType = resp.headers.get("content-type") || ""; + const data = contentType.includes("application/json") + ? await resp.json() + : await resp.text(); + if (!resp.ok) { + throw new Error( + typeof data === "string" ? data : data.detail || "请求失败 / Request failed" + ); + } + return data; + } + + async function onCatalogDownload() { + const source = $("catalog-source").value; + const url = $("catalog-url").value.trim() || null; + const magnitude_limit = Number($("magnitude-limit").value); + const data = await request("/api/catalog/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ source, url, magnitude_limit }), + }); + setOutput("catalog-status", data); + } + + async function onCatalogBuild() { + const magnitude_limit = Number($("magnitude-limit").value); + const ra_bin_size_deg = Number($("ra-bin-size").value); + const data = await request("/api/catalog/build-index", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ magnitude_limit, ra_bin_size_deg }), + }); + setOutput("catalog-status", data); + } + + async function onCatalogStatus() { + const data = await request("/api/catalog/status"); + setOutput("catalog-status", data); + } + + function readStarPayload() { + return { + source_id: $("star-source-id").value.trim(), + ra: Number($("star-ra").value), + dec: Number($("star-dec").value), + pmra: Number($("star-pmra").value), + pmdec: Number($("star-pmdec").value), + phot_g_mean_mag: Number($("star-mag").value), + name_en: $("star-name-en").value.trim(), + name_zh: $("star-name-zh").value.trim(), + description_en: $("star-desc-en").value.trim(), + description_zh: $("star-desc-zh").value.trim(), + }; + } + + async function onStarCreate() { + const payload = readStarPayload(); + const data = await request("/api/catalog/stars", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + setOutput("star-output", data); + } + + async function onStarUpdate() { + const payload = readStarPayload(); + const sid = payload.source_id; + const data = await request(`/api/catalog/stars/${encodeURIComponent(sid)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + setOutput("star-output", data); + } + + async function onStarDelete() { + const sid = $("star-source-id").value.trim(); + const data = await request(`/api/catalog/stars/${encodeURIComponent(sid)}`, { + method: "DELETE", + }); + setOutput("star-output", data); + } + + async function onStarGet() { + const sid = $("star-source-id").value.trim(); + const data = await request(`/api/catalog/stars/${encodeURIComponent(sid)}`); + setOutput("star-output", data); + } + + async function onStarList() { + const q = $("star-query").value.trim(); + const params = new URLSearchParams({ limit: "50", offset: "0" }); + if (q) params.set("source_query", q); + const data = await request(`/api/catalog/stars?${params.toString()}`); + setOutput("star-output", data); + } + + async function onUpload() { + const fileInput = $("analysis-file"); + if (!fileInput.files || fileInput.files.length === 0) { + throw new Error("请先选择文件 / Please choose a file"); + } + const file = fileInput.files[0]; + const fd = new FormData(); + fd.append("file", file); + const data = await request("/api/analysis/upload", { + method: "POST", + body: fd, + }); + state.uploadedFileName = data.filename; + setOutput("analysis-output", data); + } + + async function onSolveImage() { + if (!state.uploadedFileName) { + throw new Error("请先上传图片 / Please upload an image first"); + } + const hint_ra_deg = Number($("hint-ra").value); + const hint_dec_deg = Number($("hint-dec").value); + const params = new URLSearchParams({ + input_name: state.uploadedFileName, + hint_ra_deg: String(hint_ra_deg), + hint_dec_deg: String(hint_dec_deg), + }); + const data = await request(`/api/analysis/solve/image?${params.toString()}`, { + method: "POST", + }); + setOutput("analysis-output", data); + } + + async function onCreateVideoJob() { + if (!state.uploadedFileName) { + throw new Error("请先上传视频 / Please upload a video first"); + } + const hint_ra_deg = Number($("hint-ra").value); + const hint_dec_deg = Number($("hint-dec").value); + const frame_step = Number($("frame-step").value); + const max_frames = Number($("max-frames").value); + const data = await request("/api/analysis/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + input_name: state.uploadedFileName, + input_type: "video", + hint_ra_deg, + hint_dec_deg, + frame_step, + max_frames, + }), + }); + state.lastJobId = data.job_id; + setOutput("analysis-output", data); + } + + async function onQueryJob() { + if (!state.lastJobId) { + throw new Error("暂无任务ID / No job id"); + } + const status = await request(`/api/analysis/jobs/${state.lastJobId}`); + let result = null; + if (status.status === "succeeded") { + result = await request(`/api/analysis/jobs/${state.lastJobId}/result`); + } + setOutput("analysis-output", { status, result }); + } + + async function onRealtimeStart() { + const hint_ra_deg = Number($("hint-ra").value); + const hint_dec_deg = Number($("hint-dec").value); + const params = new URLSearchParams({ + hint_ra_deg: String(hint_ra_deg), + hint_dec_deg: String(hint_dec_deg), + }); + const data = await request( + `/api/debug/analysis/realtime/start?${params.toString()}`, + { method: "POST" } + ); + setOutput("realtime-output", data); + } + + async function onRealtimeStop() { + const data = await request("/api/debug/analysis/realtime/stop", { + method: "POST", + }); + setOutput("realtime-output", data); + } + + async function onRealtimeStatus() { + const data = await request("/api/debug/analysis/realtime/status"); + setOutput("realtime-output", data); + } + + function bindClick(id, handler) { + const node = $(id); + if (!node) return; + node.addEventListener("click", async () => { + try { + await handler(); + } catch (error) { + setOutput("analysis-output", String(error)); + setOutput("realtime-output", String(error)); + } + }); + } + + function setupAutoPoll() { + const checkbox = $("auto-poll-status"); + if (!checkbox) return; + checkbox.addEventListener("change", () => { + if (state.pollTimer) { + clearInterval(state.pollTimer); + state.pollTimer = null; + } + if (checkbox.checked) { + state.pollTimer = setInterval(() => { + onRealtimeStatus().catch(() => null); + }, 2000); + } + }); + checkbox.dispatchEvent(new Event("change")); + } + + bindClick("catalog-download-btn", onCatalogDownload); + bindClick("catalog-build-btn", onCatalogBuild); + bindClick("catalog-status-btn", onCatalogStatus); + bindClick("star-create-btn", onStarCreate); + bindClick("star-update-btn", onStarUpdate); + bindClick("star-delete-btn", onStarDelete); + bindClick("star-get-btn", onStarGet); + bindClick("star-list-btn", onStarList); + bindClick("upload-btn", onUpload); + bindClick("solve-image-btn", onSolveImage); + bindClick("create-video-job-btn", onCreateVideoJob); + bindClick("query-job-btn", onQueryJob); + bindClick("realtime-start-btn", onRealtimeStart); + bindClick("realtime-stop-btn", onRealtimeStop); + bindClick("realtime-status-btn", onRealtimeStatus); + setupAutoPoll(); + onCatalogStatus().catch(() => null); +})(); diff --git a/web/static/js/debug.js b/web/static/js/debug.js index a12fb02..41032c9 100644 --- a/web/static/js/debug.js +++ b/web/static/js/debug.js @@ -1045,24 +1045,48 @@ class DebugConsole { this.updateNoiseReductionDisplay(parseInt(e.target.value)); }); - document.getElementById('auto-exposure-mode')?.addEventListener('change', (e) => { - this.updateAutoExposureMode(e.target.value === 'auto'); + document.getElementById('auto-exposure-mode')?.addEventListener('change', () => { + this.refreshModeApplyStates(); }); document.getElementById('color-mode')?.addEventListener('change', (e) => { this.updateColorMode(e.target.value); + this.refreshModeApplyStates(); }); document.getElementById('white-balance-mode')?.addEventListener('change', (e) => { this.updateWhiteBalanceMode(e.target.value); + this.refreshModeApplyStates(); + }); + + document.getElementById('apply-auto-exposure-mode')?.addEventListener('click', async () => { + const modeSelect = document.getElementById('auto-exposure-mode'); + if (!modeSelect) { + return; + } + await this.applyAutoExposureMode(modeSelect.value === 'auto'); + }); + + document.getElementById('apply-color-mode')?.addEventListener('click', async () => { + const colorModeSelect = document.getElementById('color-mode'); + if (!colorModeSelect) { + return; + } + await this.switchColorMode(colorModeSelect.value); + }); + + document.getElementById('apply-white-balance-mode')?.addEventListener('click', async () => { + await this.applyWhiteBalanceMode(); }); document.getElementById('wb-gain-r')?.addEventListener('input', (e) => { this.updateWhiteBalanceGainR(parseFloat(e.target.value)); + this.refreshModeApplyStates(); }); document.getElementById('wb-gain-b')?.addEventListener('input', (e) => { this.updateWhiteBalanceGainB(parseFloat(e.target.value)); + this.refreshModeApplyStates(); }); // 夜间模式控制 / Night mode control @@ -1262,6 +1286,8 @@ class DebugConsole { this.updateWhiteBalanceMode(this.currentSettings.whiteBalanceMode); this.updateWhiteBalanceGainR(this.currentSettings.whiteBalanceGainR); this.updateWhiteBalanceGainB(this.currentSettings.whiteBalanceGainB); + this.updateModeCurrentDisplays(); + this.refreshModeApplyStates(); // 初始化全屏预览功能 / Initialize full screen preview function this.initFullscreenPreview(); @@ -1311,8 +1337,24 @@ class DebugConsole { const info = status.info || {}; if (typeof info.auto_exposure === 'boolean') { this.currentSettings.autoExposure = info.auto_exposure; - this.updateAutoExposureMode(info.auto_exposure); + this.updateAutoExposureMode(info.auto_exposure, false); + } + if (typeof info.color_mode === 'string') { + this.currentSettings.colorMode = info.color_mode; + this.updateColorMode(info.color_mode, false); + } + if (typeof info.white_balance_mode === 'string') { + this.currentSettings.whiteBalanceMode = info.white_balance_mode; + this.updateWhiteBalanceMode(info.white_balance_mode, false); } + if (typeof info.white_balance_gain_r === 'number') { + this.currentSettings.whiteBalanceGainR = info.white_balance_gain_r; + } + if (typeof info.white_balance_gain_b === 'number') { + this.currentSettings.whiteBalanceGainB = info.white_balance_gain_b; + } + this.updateModeCurrentDisplays(); + this.refreshModeApplyStates(); this.updateStatusUI(); this.updateInfoUI(); @@ -1958,11 +2000,11 @@ class DebugConsole { /** * 更新自动曝光模式 / Update auto-exposure mode */ - updateAutoExposureMode(isAuto) { + updateAutoExposureMode(isAuto, syncSelect = true) { this.currentSettings.autoExposure = isAuto; const modeSelect = document.getElementById('auto-exposure-mode'); - if (modeSelect) { + if (modeSelect && syncSelect) { modeSelect.value = isAuto ? 'auto' : 'manual'; } @@ -1988,9 +2030,9 @@ class DebugConsole { /** * 更新颜色模式显示 / Update color mode display */ - updateColorMode(mode) { + updateColorMode(mode, syncSelect = true) { const colorModeSelect = document.getElementById('color-mode'); - if (colorModeSelect) { + if (colorModeSelect && syncSelect) { colorModeSelect.value = mode; } @@ -2020,12 +2062,116 @@ class DebugConsole { /** * 更新白平衡模式 / Update white balance mode */ - updateWhiteBalanceMode(mode) { + updateWhiteBalanceMode(mode, syncSelect = true) { + const whiteBalanceModeSelect = document.getElementById('white-balance-mode'); + if (whiteBalanceModeSelect && syncSelect) { + whiteBalanceModeSelect.value = mode; + } + const gainsContainer = document.getElementById('white-balance-gains'); if (gainsContainer) { gainsContainer.style.display = mode === 'manual' ? 'block' : 'none'; } } + + /** + * 更新模式类参数的当前生效值显示 / Update current applied values for mode settings + */ + updateModeCurrentDisplays() { + const exposureCurrent = document.getElementById('auto-exposure-current'); + if (exposureCurrent) { + const modeSelect = document.getElementById('auto-exposure-mode'); + const effectiveAutoExposure = typeof this.currentSettings.autoExposure === 'boolean' + ? this.currentSettings.autoExposure + : (modeSelect ? modeSelect.value === 'auto' : true); + exposureCurrent.textContent = effectiveAutoExposure + ? this.t('settings.exposureModeAuto') + : this.t('settings.exposureModeManual'); + } + + const colorCurrent = document.getElementById('color-mode-current'); + if (colorCurrent) { + const colorModeSelect = document.getElementById('color-mode'); + const effectiveColorMode = this.currentSettings.colorMode + || (colorModeSelect ? colorModeSelect.value : 'color'); + colorCurrent.textContent = effectiveColorMode === 'mono' + ? this.t('settings.colorModeMono') + : this.t('settings.colorModeColor'); + } + + const whiteBalanceCurrent = document.getElementById('white-balance-mode-current'); + if (whiteBalanceCurrent) { + const whiteBalanceModeSelect = document.getElementById('white-balance-mode'); + const mode = this.currentSettings.whiteBalanceMode + || (whiteBalanceModeSelect ? whiteBalanceModeSelect.value : 'auto'); + const modeTextMap = { + auto: this.t('settings.whiteBalanceAuto'), + manual: this.t('settings.whiteBalanceManual'), + night: this.t('settings.whiteBalanceNight') + }; + whiteBalanceCurrent.textContent = modeTextMap[mode] || mode || '--'; + } + } + + /** + * 刷新模式应用按钮状态 / Refresh apply button state for mode settings + */ + refreshModeApplyStates() { + const autoExposureSelect = document.getElementById('auto-exposure-mode'); + const colorModeSelect = document.getElementById('color-mode'); + const whiteBalanceModeSelect = document.getElementById('white-balance-mode'); + + this.refreshSingleModeApplyState( + 'apply-auto-exposure-mode', + 'auto-exposure-dirty-hint', + autoExposureSelect ? (autoExposureSelect.value === 'auto') !== this.currentSettings.autoExposure : false + ); + this.refreshSingleModeApplyState( + 'apply-color-mode', + 'color-mode-dirty-hint', + colorModeSelect ? colorModeSelect.value !== this.currentSettings.colorMode : false + ); + this.refreshSingleModeApplyState( + 'apply-white-balance-mode', + 'white-balance-mode-dirty-hint', + (() => { + if (!whiteBalanceModeSelect) { + return false; + } + const modeChanged = whiteBalanceModeSelect.value !== this.currentSettings.whiteBalanceMode; + if (modeChanged) { + return true; + } + if (whiteBalanceModeSelect.value !== 'manual') { + return false; + } + const gainR = parseFloat(document.getElementById('wb-gain-r')?.value || '1.0'); + const gainB = parseFloat(document.getElementById('wb-gain-b')?.value || '1.0'); + const sameGainR = Math.abs(gainR - (this.currentSettings.whiteBalanceGainR ?? 1.0)) < 1e-6; + const sameGainB = Math.abs(gainB - (this.currentSettings.whiteBalanceGainB ?? 1.0)) < 1e-6; + return !(sameGainR && sameGainB); + })() + ); + } + + /** + * 单项模式按钮状态刷新 / Refresh single mode apply button state + */ + refreshSingleModeApplyState(buttonId, hintId, hasPendingChange) { + const applyButton = document.getElementById(buttonId); + if (applyButton) { + // 保持可点击,避免“按钮无反应”的误判;无变更时由点击逻辑给出提示 / Keep clickable to avoid "button no response"; show hint in click logic when unchanged + applyButton.disabled = false; + } + + const hint = document.getElementById(hintId); + if (hint) { + hint.textContent = hasPendingChange + ? this.t('settings.pendingUnsynced') + : this.t('settings.pendingSync'); + hint.style.color = hasPendingChange ? 'var(--debug-warning)' : 'var(--debug-text-secondary)'; + } + } /** * 更新白平衡红色增益显示 / Updated white balance red gain display @@ -2040,6 +2186,80 @@ class DebugConsole { updateWhiteBalanceGainB(value) { document.getElementById('wb-gain-b-value').textContent = value.toFixed(1); } + + /** + * 仅应用曝光模式切换 / Apply exposure mode switch only + */ + async applyAutoExposureMode(isAuto) { + if (isAuto === this.currentSettings.autoExposure) { + this.showNotification('曝光模式未变化', 'info'); + this.refreshModeApplyStates(); + return; + } + + try { + const response = await fetch(`/api/debug/camera/auto-exposure?enabled=${isAuto}`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || '曝光模式更新失败'); + } + + this.currentSettings.autoExposure = isAuto; + await this.updateCameraStatus(); + this.refreshModeApplyStates(); + } catch (error) { + console.error('[DebugConsole] 曝光模式切换失败:', error); + this.showNotification(`曝光模式切换失败: ${error.message}`, 'error'); + // 回滚到相机真实状态,避免 UI 与设备状态不一致 / Roll back to actual camera state to avoid inconsistency between UI and device state + await this.updateCameraStatus(); + this.refreshModeApplyStates(); + } + } + + /** + * 仅应用白平衡模式切换 / Apply white-balance mode switch only + */ + async applyWhiteBalanceMode() { + const modeSelect = document.getElementById('white-balance-mode'); + if (!modeSelect) { + return; + } + + const mode = modeSelect.value; + const gainR = parseFloat(document.getElementById('wb-gain-r')?.value || '1.0'); + const gainB = parseFloat(document.getElementById('wb-gain-b')?.value || '1.0'); + const sameMode = mode === this.currentSettings.whiteBalanceMode; + const sameGainR = Math.abs(gainR - (this.currentSettings.whiteBalanceGainR ?? 1.0)) < 1e-6; + const sameGainB = Math.abs(gainB - (this.currentSettings.whiteBalanceGainB ?? 1.0)) < 1e-6; + if (sameMode && (mode !== 'manual' || (sameGainR && sameGainB))) { + this.showNotification('白平衡参数未变化', 'info'); + this.refreshModeApplyStates(); + return; + } + + try { + const response = await fetch( + `/api/debug/camera/white-balance?mode=${encodeURIComponent(mode)}&gain_r=${gainR}&gain_b=${gainB}`, + { method: 'POST' } + ); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || '白平衡模式切换失败'); + } + + this.currentSettings.whiteBalanceMode = mode; + await this.updateCameraStatus(); + this.refreshModeApplyStates(); + } catch (error) { + console.error('[DebugConsole] 白平衡模式切换失败:', error); + this.showNotification(`白平衡模式切换失败: ${error.message}`, 'error'); + await this.updateCameraStatus(); + this.refreshModeApplyStates(); + } + } /** * 应用设置 / Apply settings @@ -2049,24 +2269,14 @@ class DebugConsole { exposure: parseInt(document.getElementById('exposure-setting').value), gain: parseFloat(document.getElementById('gain-setting').value), digitalGain: parseFloat(document.getElementById('digital-gain-setting').value), - autoExposure: document.getElementById('auto-exposure-mode').value === 'auto', contrast: parseFloat(document.getElementById('contrast-setting').value), brightness: parseFloat(document.getElementById('brightness-setting').value), saturation: parseFloat(document.getElementById('saturation-setting').value), sharpness: parseFloat(document.getElementById('sharpness-setting').value), - noiseReduction: parseInt(document.getElementById('noise-reduction-setting').value), - whiteBalanceMode: document.getElementById('white-balance-mode').value, - whiteBalanceGainR: parseFloat(document.getElementById('wb-gain-r').value), - whiteBalanceGainB: parseFloat(document.getElementById('wb-gain-b').value), - colorMode: document.getElementById('color-mode').value + noiseReduction: parseInt(document.getElementById('noise-reduction-setting').value) }; try { - // 先处理颜色模式切换(如果需要) / Handle color mode switching first (if needed) - if (settings.colorMode !== this.currentSettings.colorMode) { - await this.switchColorMode(settings.colorMode); - } - const response = await fetch('/api/debug/camera/settings', { method: 'POST', headers: { @@ -2093,6 +2303,11 @@ class DebugConsole { * 切换颜色模式 / Switch color mode */ async switchColorMode(colorMode) { + if (colorMode === this.currentSettings.colorMode) { + this.showNotification('颜色模式未变化', 'info'); + this.refreshModeApplyStates(); + return; + } try { this.showNotification(`正在切换到${colorMode === 'mono' ? '黑白' : '彩色'}模式...`, 'info'); @@ -2108,6 +2323,7 @@ class DebugConsole { // 刷新相机状态 / Refresh camera status await this.updateCameraStatus(); + this.refreshModeApplyStates(); // 给用户一些性能提示 / Give users some performance tips if (colorMode === 'mono') { @@ -2125,6 +2341,7 @@ class DebugConsole { // 恢复UI状态 / Restore UI state this.updateColorMode(this.currentSettings.colorMode); + this.refreshModeApplyStates(); } } @@ -2627,6 +2844,8 @@ class DebugConsole { this.updateWhiteBalanceGainR(presetData.white_balance_gain_r ?? 1.0); this.updateWhiteBalanceGainB(presetData.white_balance_gain_b ?? 1.0); this.updateColorMode(presetData.color_mode ?? 'color'); + this.updateModeCurrentDisplays(); + this.refreshModeApplyStates(); // 更新旋转角度 / Update rotation angle if (presetData.rotation !== undefined) { @@ -3056,8 +3275,15 @@ class DebugConsole { this.updateNoiseReductionDisplay(preset.noiseReduction); this.updateWhiteBalanceMode(preset.whiteBalanceMode); - // 快速预设属于手动调参场景,自动切换到手动曝光 / Quick preset is a manual parameter adjustment scene and automatically switches to manual exposure. - this.updateAutoExposureMode(false); + // 快速预设属于手动调参场景,先将待应用值切到手动曝光 / Quick preset is a manual tuning scenario, set pending value to manual exposure first. + const autoExposureSelect = document.getElementById('auto-exposure-mode'); + if (autoExposureSelect) { + autoExposureSelect.value = 'manual'; + } + this.refreshModeApplyStates(); + + await this.applyAutoExposureMode(false); + await this.applyWhiteBalanceMode(); // 应用设置 / Apply settings await this.applySettings(); diff --git a/web/templates/debug.html b/web/templates/debug.html index 367d780..3efea8f 100644 --- a/web/templates/debug.html +++ b/web/templates/debug.html @@ -19,7 +19,7 @@ - +