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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user