Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ Thumbs.db
# ====================
# 用户上传的图像
uploads/
data/dev_captures/
captured_images/
*.fits
*.fit
Expand Down Expand Up @@ -205,4 +206,3 @@ TODO.md
NOTES.md
.obsidian/
PiFinder-release

9 changes: 7 additions & 2 deletions docs/DEBUG_CONSOLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ python -m ogscope.web.app
4. **拍摄照片**
- 切换到 "拍摄控制" 标签页
- 点击 "拍摄照片" 按钮
- 照片自动保存到 `~/dev_captures/` 目录
- 照片默认保存到持久化目录 `./data/dev_captures/`
- 可用 `OGSCOPE_DEV_CAPTURES_DIR` 指定其他目录;指向 `/tmp` 或 `/run` 时界面会警告重启后清空

5. **录制视频**
- 点击 "开始录制" 按钮
Expand All @@ -116,6 +117,7 @@ python -m ogscope.web.app
- 切换到 "文件管理" 标签页
- 查看所有拍摄文件
- 点击 "下载" 下载文件到本地
- 点击 "导出全部" 下载包含媒体、参数侧车和预设的 ZIP 备份
- 点击 "详情" 查看拍摄参数

### 键盘快捷键
Expand All @@ -129,14 +131,17 @@ python -m ogscope.web.app
## 📂 文件结构

```
~/dev_captures/ # 拍摄文件存储目录
./data/dev_captures/ # 默认持久化拍摄文件目录
├── IMG_20241201_143022.jpg # 拍摄的照片
├── IMG_20241201_143022.txt # 对应的参数文件
├── VID_20241201_143045.mp4 # 录制的视频
├── VID_20241201_143045.txt # 对应的参数文件
└── presets.json # 预设配置文件
```

服务首次使用持久化目录时,会从历史的 `/tmp/dev_captures/` 和
`~/dev_captures/` 无覆盖复制现有文件。源文件保留,同名的持久化文件优先。

### 参数文件格式

每个拍摄文件都会生成对应的 `.txt` 参数文件,包含以下信息:
Expand Down
10 changes: 7 additions & 3 deletions docs/DEBUG_CONSOLE_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@ Browser: `http://localhost:8000/debug`
1. **Start preview** — click Start, wait for init, view stream.
2. **Tune parameters** — Parameters tab, adjust sliders, Apply.
3. **Calibrate focus (optional)** — start focus calibration, rotate the lens slowly, and minimize HFD. Click a preview star when you want to lock the target.
4. **Capture still** — Capture tab, Capture photo; files under `~/dev_captures/`.
4. **Capture still** — Capture tab, Capture photo; files persist under `./data/dev_captures/` by default. Set `OGSCOPE_DEV_CAPTURES_DIR` to override it. Paths under `/tmp` or `/run` are marked as temporary in the UI and are cleared by a reboot.
5. **Record video** — Start recording, stop when done.
6. **Presets** — Presets tab: name, description, Save; Apply from cards.
7. **Files** — Files tab: list, download, details.
7. **Files** — Files tab: list, download, export all media and sidecars as a ZIP, or inspect details.

### Keyboard shortcuts

Expand All @@ -92,14 +92,18 @@ Browser: `http://localhost:8000/debug`
## File layout

```
~/dev_captures/
./data/dev_captures/
├── IMG_20241201_143022.jpg
├── IMG_20241201_143022.txt
├── VID_20241201_143045.mp4
├── VID_20241201_143045.txt
└── presets.json
```

When persistent capture storage is first used, OGScope copies existing files from
legacy `/tmp/dev_captures/` and `~/dev_captures/` directories without overwriting
newer persistent files. Legacy source files remain in place.

### Sidecar format (example)

```json
Expand Down
4 changes: 4 additions & 0 deletions docs/contracts/core-rest-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,14 @@
- `session_id: str`
- `state: "running" | "completed" | "stopped"`
- `result: object | null`
- `observation_time_utc`:可选,当前图像曝光中点 UTC;天文坐标换算应优先使用该时刻
- `capture_completed_at_utc`、`capture_exposure_us`:可选抓帧诊断字段
- `last_error: str`
- `frame_count: int`
- `fullsolve_count: int`

Core 实时分析运行时,开发者相机单帧解算返回 `SKIPPED_BUSY`,避免调试轮询与产品对准争抢相机和 CPU;文件解算不受影响。

### 3) Stop Analysis

- `POST /api/core/v1/analysis/stop`
Expand Down
4 changes: 4 additions & 0 deletions docs/contracts/core-rest-v1_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,14 @@ upstream consumers should ignore results from an older session.
- `session_id: str`
- `state: "running" | "completed" | "stopped"`
- `result: object | null`
- `observation_time_utc`: optional exposure-midpoint UTC for the current frame; astronomical coordinate conversion should prefer it
- `capture_completed_at_utc`, `capture_exposure_us`: optional capture diagnostics
- `last_error: str`
- `frame_count: int`
- `fullsolve_count: int`

While Core realtime analysis is active, developer single-frame camera solves return `SKIPPED_BUSY` so debug polling cannot contend with product alignment for camera and CPU resources. File solving is unaffected.

### 3) Stop Analysis

- `POST /api/core/v1/analysis/stop`
Expand Down
11 changes: 11 additions & 0 deletions ogscope/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,13 @@ class Settings(BaseSettings):
# 文件路径配置 / File path configuration
data_dir: Path = Field(default=Path("./data"), description="数据目录")
upload_dir: Path = Field(default=Path("./uploads"), description="上传目录")
dev_captures_dir: Optional[Path] = Field(
default=None,
description=(
"开发者相机拍摄持久化目录;None 时使用 data/dev_captures / "
"Persistent developer-camera capture directory; defaults to data/dev_captures"
),
)
analysis_dir: Path = Field(
default=Path("./data/analysis"), description="分析任务目录"
)
Expand Down Expand Up @@ -613,9 +620,13 @@ def _apply_development_mode_defaults(self) -> "Settings":

def __init__(self, **kwargs):
super().__init__(**kwargs)
if self.dev_captures_dir is None:
object.__setattr__(self, "dev_captures_dir", self.data_dir / "dev_captures")
# 创建必要的目录 / Create necessary directories
self.data_dir.mkdir(parents=True, exist_ok=True)
self.upload_dir.mkdir(parents=True, exist_ok=True)
assert self.dev_captures_dir is not None
self.dev_captures_dir.mkdir(parents=True, exist_ok=True)
self.analysis_dir.mkdir(parents=True, exist_ok=True)
self.plate_solve_dir.mkdir(parents=True, exist_ok=True)

Expand Down
1 change: 1 addition & 0 deletions ogscope/config_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"database_url",
"data_dir",
"upload_dir",
"dev_captures_dir",
"analysis_dir",
"plate_solve_dir",
"solver_tetra_database_path",
Expand Down
59 changes: 56 additions & 3 deletions ogscope/core/realtime/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any

from loguru import logger
Expand Down Expand Up @@ -57,6 +58,40 @@ def __init__(self) -> None:
1.0 / max(0.01, float(settings.star_analysis_target_fps)),
)

@staticmethod
def _capture_time_payload(
capture_completed_ts: float,
camera_info: dict[str, Any] | None,
) -> dict[str, Any]:
"""Build exposure-midpoint UTC telemetry / 构造曝光中点 UTC 遥测。"""
try:
completed_ts = float(capture_completed_ts)
if completed_ts <= 0:
return {}
info = camera_info if isinstance(camera_info, dict) else {}
exposure_us = max(
0,
int(info.get("actual_exposure_us", info.get("exposure_us", 0)) or 0),
)
midpoint_ts = completed_ts - exposure_us / 2_000_000.0
completed_iso = (
datetime.fromtimestamp(completed_ts, timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
midpoint_iso = (
datetime.fromtimestamp(midpoint_ts, timezone.utc)
.isoformat()
.replace("+00:00", "Z")
)
except (OSError, OverflowError, TypeError, ValueError):
return {}
return {
"observation_time_utc": midpoint_iso,
"capture_completed_at_utc": completed_iso,
"capture_exposure_us": exposure_us,
}

async def start(
self,
hint_ra_deg: float | None = None,
Expand Down Expand Up @@ -152,7 +187,7 @@ async def _loop(self) -> None:
# 必须与共享预览走同一套读锁 + 线程卸载,禁止在事件循环线程里直接 capture_array
# Must share the same read lock as shared preview; never call capture_array on the event-loop thread.
try:
frame, frame_id, _ts = await manager.get_raw_frame()
frame, frame_id, frame_ts = await manager.get_raw_frame()
except RuntimeError:
await asyncio.sleep(0.1)
continue
Expand All @@ -165,6 +200,13 @@ async def _loop(self) -> None:
last_frame_id = frame_id
last_started_mono = time.monotonic()
self.state.frame_count += 1
try:
camera_info = cam.get_camera_info()
except (
Exception
): # noqa: BLE001 - timing remains optional / 时间遥测可降级
camera_info = {}
capture_time = self._capture_time_payload(frame_ts, camera_info)

use_fullsolve = (
self.state.frame_count % self._fullsolve_interval == 0
Expand All @@ -178,7 +220,7 @@ async def _loop(self) -> None:
self._solve_frame_sync,
frame,
)
self._apply_solve_result(solved)
self._apply_solve_result(solved, capture_time=capture_time)
self.state.fullsolve_count += 1
self._log_event(
"fullsolve_finished",
Expand All @@ -191,6 +233,8 @@ async def _loop(self) -> None:
t_extract_ms=solved.t_extract_ms,
t_preprocess_ms=solved.t_preprocess_ms,
wall_ms=int((time.monotonic() - solve_started) * 1000),
rmse_arcsec=solved.rmse_arcsec,
observation_time_utc=capture_time.get("observation_time_utc"),
)
# ``solve_from_bgr_frame`` is the authoritative production
# image pipeline. Keep only a sentinel here; StarExtractor
Expand Down Expand Up @@ -236,9 +280,18 @@ def _solve_frame_sync(
solve_timeout_ms=self._solve_timeout_ms,
)

def _apply_solve_result(self, solved: SolveResult) -> None:
def _apply_solve_result(
self,
solved: SolveResult,
*,
capture_time: dict[str, Any] | None = None,
) -> None:
"""写入解算结果 / Persist solve result"""
row = solved.to_dict()
if capture_time:
# Optional additive Core fields keep old consumers compatible. /
# 可选增量字段保持旧版 Core 消费方兼容。
row.update(capture_time)
attach_sensor_prediction(row, self._solve_context)
self.state.last_result = row
# A later completed frame supersedes a transient capture/solve exception.
Expand Down
4 changes: 4 additions & 0 deletions ogscope/domain/camera/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,10 @@ class DebugFileService:
async def get_files():
return await _debug_services_module().DebugFileService.get_files()

@staticmethod
async def create_export_archive():
return await _debug_services_module().DebugFileService.create_export_archive()

@staticmethod
async def get_file_info(filename: str):
return await _debug_services_module().DebugFileService.get_file_info(filename)
Expand Down
82 changes: 80 additions & 2 deletions ogscope/domain/shared/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@

from __future__ import annotations

import os
import shutil
import tempfile
from collections.abc import Iterable
from pathlib import Path, PurePath

DEV_CAPTURES_DIR = Path("/tmp/dev_captures")
DEV_CAPTURES_DIR.mkdir(exist_ok=True)
from ogscope.config import get_settings

_configured_dev_captures_dir = get_settings().dev_captures_dir
assert _configured_dev_captures_dir is not None
DEV_CAPTURES_DIR = Path(_configured_dev_captures_dir)
DEV_CAPTURES_DIR.mkdir(parents=True, exist_ok=True)

IMAGE_EXTENSIONS = {
".jpg",
Expand Down Expand Up @@ -39,3 +47,73 @@ def ensure_safe_basename(filename: str) -> str:
if "/" in safe_name or "\\" in safe_name:
raise ValueError("invalid filename")
return safe_name


def dev_captures_storage_info(path: Path = DEV_CAPTURES_DIR) -> dict[str, object]:
"""返回调试拍摄目录的持久化语义 / Describe capture storage persistence."""
resolved = path.expanduser().resolve()
volatile_roots = {
Path("/tmp").resolve(),
Path("/run").resolve(),
Path(tempfile.gettempdir()).resolve(),
}
is_temporary = any(
resolved == root or root in resolved.parents for root in volatile_roots
)
return {
"path": str(resolved),
"persistence": "temporary" if is_temporary else "persistent",
"is_persistent": not is_temporary,
}


def migrate_legacy_dev_captures(
legacy_dirs: Iterable[Path] | None = None,
target_dir: Path = DEV_CAPTURES_DIR,
) -> dict[str, object]:
"""
无覆盖迁移历史调试拍摄文件 / Migrate legacy captures without overwriting.

源文件保留不删,避免更新中断时两边都丢失。
Source files stay in place so an interrupted update cannot lose both copies.
"""
target = target_dir.expanduser().resolve()
target.mkdir(parents=True, exist_ok=True)
sources = tuple(
(Path("/tmp/dev_captures"), Path.home() / "dev_captures")
if legacy_dirs is None
else legacy_dirs
)
migrated: list[str] = []
skipped: list[str] = []
errors: list[str] = []

for source_dir in sources:
source = source_dir.expanduser().resolve()
if source == target or not source.is_dir():
continue
try:
source_paths = sorted(source.iterdir(), key=lambda item: item.name)
except OSError as exc:
errors.append(f"{source}: {exc}")
continue
for source_path in source_paths:
if not source_path.is_file() or source_path.is_symlink():
continue
try:
safe_name = ensure_safe_basename(source_path.name)
destination = target / safe_name
if destination.exists():
skipped.append(safe_name)
continue
temporary = target / f".{safe_name}.migrating-{os.getpid()}"
try:
shutil.copy2(source_path, temporary)
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
migrated.append(safe_name)
except (OSError, ValueError) as exc:
errors.append(f"{source_path}: {exc}")

return {"migrated": migrated, "skipped": skipped, "errors": errors}
Loading
Loading