Files
FishServer/fish_api/app/services/measure_watch.py
zaiun xu 5e1b2117c1 feat(fish_api): SQLite 快照投递、日志与 watch 空闲告警
- 新增 SQLite:measure/health 快照、delivery_cursor 单消费者 pop;clear/start_fresh 可清空库
- biomass GET 仅返回约定 data 字段,X-Fish-Biomass-New 表示是否有新快照;poller 读响应头
- loguru 桥接 uvicorn,子进程 stdout 流式输出;format_json_pretty 与算法摘要日志
- measure/action watch 无新任务时限流 WARNING;watch_idle 共用逻辑
- 依赖 loguru;新增 db、logging_config、subprocess_run、watch_idle、启动脚本

FishMeasure: 更新 fish_video_weight_evaluation 与 predict_weigth_from_svo2;移除未用 refbox/segmentation 脚本
Made-with: Cursor
2026-04-09 11:54:30 +08:00

161 lines
5.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""后台轮询目录中的 .svo2跑 FishMeasure写入 SQLite与 ingest 共用)。"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Dict, Set
from loguru import logger
from app.db import add_watch_processed, load_watch_processed, save_measure_snapshot
from app.services import measure as measure_svc
from app.settings import Settings
from app.state import MeasureSnapshot, app_state
from app.watch_idle import IdleWatchWarnState, idle_warn_interval_sec, maybe_warn_idle_watch
_MEASURE_IDLE_WARN_INTERVAL_SEC = idle_warn_interval_sec(
"FISH_MEASURE_WATCH_IDLE_WARN_INTERVAL_SEC"
)
def _state_path(settings: Settings) -> Path:
if settings.measure_watch_state_file is not None:
return settings.measure_watch_state_file
assert settings.measure_watch_dir is not None
return settings.measure_watch_dir / ".fishmeasure_watch_processed.json"
def iter_svo2(watch_dir: Path, recursive: bool) -> list[Path]:
if recursive:
return sorted(
p
for p in watch_dir.rglob("*")
if p.is_file() and p.suffix.lower() == ".svo2"
)
return sorted(
p
for p in watch_dir.iterdir()
if p.is_file() and p.suffix.lower() == ".svo2"
)
async def _run_measure_and_state(
svo: Path,
settings: Settings,
processed: Set[str],
state_file: Path,
) -> None:
key = str(svo.resolve())
if key in processed:
return
logger.info("[measure-watch] inference: {}", svo)
async with app_state.measure_lock:
app_state.measure_status = "running"
try:
snap = await asyncio.to_thread(measure_svc.run_full_measure, svo, settings)
save_measure_snapshot(settings, snap, source_path=key)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
r0 = snap.result[0] if snap.result else {}
w = r0.get("weight", "")
logger.info("[measure-watch] done: {} weight={!r}", svo.name, w)
except Exception as e:
save_measure_snapshot(
settings,
MeasureSnapshot(
result=[],
video_left="",
video_right="",
error=str(e),
),
source_path=key,
)
app_state.measure_status = "error"
logger.exception("[measure-watch] error on {}: {}", svo, e)
raise
async def watch_tick(
settings: Settings,
processed: Set[str],
stability: Dict[str, tuple[int, int]],
state_file: Path,
) -> bool:
assert settings.measure_watch_dir is not None
watch_dir = settings.measure_watch_dir
did = False
seen_keys: Set[str] = set()
for svo in iter_svo2(watch_dir, settings.measure_watch_recursive):
key = str(svo.resolve())
seen_keys.add(key)
if key in processed:
continue
try:
st = svo.stat()
except OSError:
continue
size = int(st.st_size)
if size <= 0:
stability.pop(key, None)
continue
last = stability.get(key)
if last is None or last[0] != size:
stability[key] = (size, 1)
else:
_, cnt = last
stability[key] = (size, cnt + 1)
_, cnt = stability[key]
if cnt >= settings.measure_watch_stable_polls:
try:
await _run_measure_and_state(svo, settings, processed, state_file)
stability.pop(key, None)
did = True
except Exception:
stability[key] = (size, 1)
for k in list(stability.keys()):
if k not in seen_keys:
del stability[k]
return did
async def run_measure_watch_loop(settings: Settings) -> None:
assert settings.measure_watch_dir is not None
wd = settings.measure_watch_dir
if not wd.is_dir():
logger.warning("[measure-watch] skip: not a directory: {}", wd)
return
state_file = _state_path(settings)
processed: Set[str] = (
load_watch_processed(settings, state_file, "measure")
if settings.measure_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
logger.info(
"[measure-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
wd,
settings.measure_watch_poll_interval,
settings.measure_watch_stable_polls,
"on" if settings.measure_watch_use_state_file else "off",
state_file if settings.measure_watch_use_state_file else "",
)
idle_warn_state = IdleWatchWarnState()
while True:
did = await watch_tick(settings, processed, stability, state_file)
maybe_warn_idle_watch(
did_work=did,
log_tag="measure-watch",
algo_name="FishMeasure",
idle_hint="目录内无 .svo2、已全部处理完毕或文件尚未达到稳定轮询次数",
watch_dir=wd,
state=idle_warn_state,
interval_sec=_MEASURE_IDLE_WARN_INTERVAL_SEC,
)
await asyncio.sleep(max(settings.measure_watch_poll_interval, 0.1))