cli to control zed camera start and stop. 2. measure now use every svo2 file for 1 fish, give intermideate result and final result with confidecne level(*).

This commit is contained in:
kevin
2026-04-16 11:38:30 +08:00
parent 9dce487c79
commit cc6cef0f73
57 changed files with 1877 additions and 386 deletions

View File

@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import math
import re
import shutil
import sqlite3
from datetime import datetime, timezone
@@ -23,7 +24,7 @@ DEFAULT_CLIENT_ID = "default"
MAX_CLIENT_ID_LEN = 128
# 客户端切片索引起缓存:记录每个 client_id 上次返回的切片索引(用于对齐 water/video 端点)
_client_health_slice_index: dict[str, int] = {}
_client_health_slice_index = {} # type: Dict[str, int]
def _parse_slice_index_from_source_path(source_path: Optional[str]) -> int:
@@ -117,6 +118,17 @@ def init_db(settings: Settings) -> None:
last_delivered_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (client_id, kind)
);
CREATE TABLE IF NOT EXISTS zed_recording_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fish_id INTEGER NOT NULL,
started_at TEXT NOT NULL,
stopped_at TEXT,
output_dir TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_zed_sessions_fish_id
ON zed_recording_sessions(fish_id);
"""
)
_migrate_delivery_cursor_from_legacy(conn)
@@ -128,6 +140,147 @@ def init_db(settings: Settings) -> None:
conn.close()
_FISH_DIR_RE = re.compile(r"^fish(\d+)$")
def _max_numeric_fish_folder_id(parent: Path) -> int:
"""``parent`` 下名为 ``fish{N}``N 为数字)的直接子目录中,最大的 N无则 0。"""
if not parent.is_dir():
return 0
m = 0
try:
for p in parent.iterdir():
if not p.is_dir():
continue
mo = _FISH_DIR_RE.match(p.name)
if mo:
m = max(m, int(mo.group(1)))
except OSError:
return 0
return m
def _max_fish_id_from_svo2_under_parent(parent: Path) -> int:
"""扫描 ``parent`` 下 ``fish{{N}}/.../*.svo2``,从相对路径第一段取最大 N库未记会话时仍能发现已有数据"""
if not parent.is_dir():
return 0
m = 0
try:
for p in parent.rglob("*.svo2"):
try:
rel = p.relative_to(parent)
except ValueError:
continue
if not rel.parts:
continue
mo = _FISH_DIR_RE.match(rel.parts[0])
if mo:
m = max(m, int(mo.group(1)))
except OSError:
return 0
return m
def _max_fish_id_from_zed_sessions(conn: sqlite3.Connection) -> int:
"""合并 ``fish_id`` 列与 ``output_dir`` 路径中出现的 ``fish{{N}}`` 段(防库与路径不一致)。"""
m = 0
for row in conn.execute("SELECT fish_id, output_dir FROM zed_recording_sessions"):
m = max(m, int(row["fish_id"]))
od = row["output_dir"]
if not od:
continue
try:
for part in Path(str(od)).parts:
mo = _FISH_DIR_RE.match(part)
if mo:
m = max(m, int(mo.group(1)))
except (ValueError, OSError):
pass
return m
def begin_zed_recording_session(settings: Settings) -> Tuple[int, int, Path]:
"""为本次录制分配 ``fish_id`` 并写入 ``zed_recording_sessions``。
编号取库内会话、父目录下 ``fish``+数字 子目录、以及其下 ``.svo2`` 文件路径所反映编号三者的最大值再加 1
避免目录里已有数据而库尚未记录时仍复用同一编号;若目标路径仍存在则顺延直至可用。
返回 ``(session_row_id, fish_id, output_dir)``。目录规则:
若配置了 ``MEASURE_WATCH_DIR`` 则为 ``{MEASURE_WATCH_DIR}/fish{N}``
否则为 ``{STREAM_TMP_DIR}/zed_svo2/fish{N}``。
"""
init_db(settings)
conn = _connect(settings.sqlite_path)
try:
conn.execute("BEGIN IMMEDIATE")
db_max = _max_fish_id_from_zed_sessions(conn)
if settings.measure_watch_dir is not None:
parent = settings.measure_watch_dir.resolve()
else:
parent = (settings.stream_tmp_dir / "zed_svo2").resolve()
fs_dir = _max_numeric_fish_folder_id(parent)
fs_svo = _max_fish_id_from_svo2_under_parent(parent)
fs_max = max(fs_dir, fs_svo)
fish_id = max(db_max, fs_max) + 1
ts = datetime.now(timezone.utc).isoformat()
output_dir = (parent / f"fish{fish_id}").resolve()
# 极端情况:并发或其它原因导致目录已存在,则顺延编号
for _ in range(10000):
if not output_dir.exists():
break
fish_id += 1
output_dir = (parent / f"fish{fish_id}").resolve()
else:
raise RuntimeError(
f"无法为 ZED 录制分配空闲 fish 目录(父目录 {parent}"
)
output_dir.mkdir(parents=True, exist_ok=True)
cur = conn.execute(
"""
INSERT INTO zed_recording_sessions (fish_id, started_at, stopped_at, output_dir)
VALUES (?, ?, NULL, ?)
""",
(fish_id, ts, str(output_dir)),
)
session_id = int(cur.lastrowid)
conn.commit()
return (session_id, fish_id, output_dir)
except Exception:
conn.rollback()
raise
finally:
conn.close()
def mark_zed_recording_session_stopped(
settings: Settings, session_row_id: int
) -> Optional[int]:
"""将对应会话行的 ``stopped_at`` 置为当前时间;返回 ``fish_id``,未更新则 ``None``。"""
init_db(settings)
conn = _connect(settings.sqlite_path)
try:
ts = datetime.now(timezone.utc).isoformat()
cur = conn.execute(
"""
UPDATE zed_recording_sessions
SET stopped_at = ?
WHERE id = ? AND stopped_at IS NULL
""",
(ts, session_row_id),
)
if cur.rowcount == 0:
conn.commit()
return None
row = conn.execute(
"SELECT fish_id FROM zed_recording_sessions WHERE id = ?",
(session_row_id,),
).fetchone()
conn.commit()
return int(row["fish_id"]) if row else None
finally:
conn.close()
def _migrate_add_client_id_column(conn: sqlite3.Connection) -> None:
"""为旧数据库添加 client_id 列(如果不存在)。"""
row = conn.execute(
@@ -376,6 +529,37 @@ def list_all_measure_snapshots(settings: Settings) -> List[Dict[str, Any]]:
conn.close()
def list_all_health_snapshots(settings: Settings) -> List[Dict[str, Any]]:
"""返回 ``health_snapshots`` 全部行id 降序,最新在前),供调试接口使用。"""
init_db(settings)
conn = _connect(settings.sqlite_path)
try:
rows = conn.execute(
"""
SELECT id, created_at, behavior_result, health_result,
raw_class_en, error, source_path
FROM health_snapshots
ORDER BY id DESC
"""
).fetchall()
out: List[Dict[str, Any]] = []
for row in rows:
out.append(
{
"id": row["id"],
"created_at": row["created_at"],
"behavior_result": row["behavior_result"] or "",
"health_result": row["health_result"] or "",
"raw_class_en": row["raw_class_en"] or "",
"error": row["error"],
"source_path": row["source_path"],
}
)
return out
finally:
conn.close()
def get_latest_health(settings: Settings) -> HealthSnapshot:
init_db(settings)
conn = _connect(settings.sqlite_path)