2026-04-09 11:54:30 +08:00
|
|
|
|
"""后台轮询目录中的 .svo2,跑 FishMeasure,写入 SQLite(与 ingest 共用)。"""
|
2026-04-08 20:35:55 +08:00
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Dict, Set
|
|
|
|
|
|
|
2026-04-09 11:54:30 +08:00
|
|
|
|
from loguru import logger
|
|
|
|
|
|
|
|
|
|
|
|
from app.db import add_watch_processed, load_watch_processed, save_measure_snapshot
|
2026-04-08 20:35:55 +08:00
|
|
|
|
from app.services import measure as measure_svc
|
|
|
|
|
|
from app.settings import Settings
|
|
|
|
|
|
from app.state import MeasureSnapshot, app_state
|
2026-04-09 11:54:30 +08:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-04-09 11:54:30 +08:00
|
|
|
|
logger.info("[measure-watch] inference: {}", svo)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
async with app_state.measure_lock:
|
|
|
|
|
|
app_state.measure_status = "running"
|
|
|
|
|
|
try:
|
|
|
|
|
|
snap = await asyncio.to_thread(measure_svc.run_full_measure, svo, settings)
|
2026-04-09 11:54:30 +08:00
|
|
|
|
save_measure_snapshot(settings, snap, source_path=key)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
app_state.measure_status = "idle"
|
|
|
|
|
|
processed.add(key)
|
|
|
|
|
|
if settings.measure_watch_use_state_file:
|
2026-04-09 11:54:30 +08:00
|
|
|
|
add_watch_processed(settings, key, "measure")
|
2026-04-08 20:35:55 +08:00
|
|
|
|
r0 = snap.result[0] if snap.result else {}
|
|
|
|
|
|
w = r0.get("weight", "")
|
2026-04-09 11:54:30 +08:00
|
|
|
|
logger.info("[measure-watch] done: {} weight={!r}", svo.name, w)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
except Exception as e:
|
2026-04-09 11:54:30 +08:00
|
|
|
|
save_measure_snapshot(
|
|
|
|
|
|
settings,
|
|
|
|
|
|
MeasureSnapshot(
|
|
|
|
|
|
result=[],
|
|
|
|
|
|
video_left="",
|
|
|
|
|
|
video_right="",
|
|
|
|
|
|
error=str(e),
|
|
|
|
|
|
),
|
|
|
|
|
|
source_path=key,
|
2026-04-08 20:35:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
app_state.measure_status = "error"
|
2026-04-09 11:54:30 +08:00
|
|
|
|
logger.exception("[measure-watch] error on {}: {}", svo, e)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
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():
|
2026-04-09 11:54:30 +08:00
|
|
|
|
logger.warning("[measure-watch] skip: not a directory: {}", wd)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
state_file = _state_path(settings)
|
|
|
|
|
|
processed: Set[str] = (
|
2026-04-09 11:54:30 +08:00
|
|
|
|
load_watch_processed(settings, state_file, "measure")
|
|
|
|
|
|
if settings.measure_watch_use_state_file
|
|
|
|
|
|
else set()
|
2026-04-08 20:35:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
stability: Dict[str, tuple[int, int]] = {}
|
|
|
|
|
|
|
2026-04-09 11:54:30 +08:00
|
|
|
|
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 "",
|
2026-04-08 20:35:55 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-09 11:54:30 +08:00
|
|
|
|
idle_warn_state = IdleWatchWarnState()
|
2026-04-08 20:35:55 +08:00
|
|
|
|
while True:
|
2026-04-09 11:54:30 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-04-08 20:35:55 +08:00
|
|
|
|
await asyncio.sleep(max(settings.measure_watch_poll_interval, 0.1))
|