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

@@ -1,13 +1,16 @@
"""后台轮询目录中的 .svo2跑 FishMeasure写入 SQLite(与 ingest 共用)。"""
"""后台轮询目录中的 .svo2逐段跑 FishMeasure齐套后聚合 final(与 ingest 共用 SQLite)。"""
from __future__ import annotations
import asyncio
import hashlib
from pathlib import Path
from typing import Dict, Set
from typing import Dict, List, Set, Tuple
from loguru import logger
from app.compat import to_thread
from app.db import (
add_watch_processed,
load_watch_processed,
@@ -30,143 +33,256 @@ def _state_path(settings: Settings) -> Path:
return settings.measure_watch_dir / ".fishmeasure_watch_processed.json"
def iter_svo2_folders(watch_dir: Path) -> list[tuple[list[Path], str]]:
def iter_svo2_folders(watch_dir: Path) -> List[Tuple[List[Path], str]]:
"""扫描子文件夹,返回 (svo文件路径列表, fish_id) 列表。
文件夹命名格式为 fish{N},如 fish1、fish2 等
每个子文件夹可以包含多个 .svo2 文件(同一条鱼的多段视频),
这些 SVO 文件会被批量处理,点云合并后进行重量预测。
文件夹命名格式为 fish{N}。每个子文件夹内多个 .svo2 先逐段测量,齐套后再聚合 final
"""
result: list[tuple[list[Path], str]] = []
result = [] # type: List[Tuple[List[Path], str]]
if not watch_dir.is_dir():
return result
for entry in sorted(watch_dir.iterdir()):
if not entry.is_dir():
continue
# 从文件夹名提取 fish_id格式为 fish{N}
folder_name = entry.name
if not folder_name.startswith("fish"):
continue
try:
fish_id = folder_name[4:] # 去掉 "fish" 前缀
fish_id = folder_name[4:]
if not fish_id.isdigit():
continue
except (IndexError, ValueError):
continue
# 在子文件夹中查找所有 .svo2 文件
svo_files = sorted([
p for p in entry.iterdir()
if p.is_file() and p.suffix.lower() == ".svo2"
])
if svo_files:
# 返回该文件夹中的所有 SVO 文件,它们将被批量处理
result.append((svo_files, fish_id))
return result
async def _run_measure_and_state(
svo_list: list[Path],
def _final_processed_key(fish_id: str, svo_list: List[Path]) -> str:
sig = "|".join(sorted(str(p.resolve()) for p in svo_list))
h = hashlib.sha256(sig.encode("utf-8")).hexdigest()[:24]
return f"__measure_final__fish{fish_id}:{h}"
def _folder_size_tuple(svo_list: List[Path]) -> Tuple[Tuple[str, int], ...]:
out: List[Tuple[str, int]] = []
for p in sorted(svo_list, key=lambda x: str(x.resolve())):
try:
st = p.stat()
out.append((str(p.resolve()), int(st.st_size)))
except OSError:
return tuple()
return tuple(out)
async def _run_single_svo_measure(
svo: Path,
fish_id: str,
settings: Settings,
processed: Set[str],
state_file: Path,
) -> None:
"""批量处理同一条鱼的多个 SVO合并点云后一次 DGCNN解析与 test_dgcnn summary 对齐。
"""
if not svo_list:
return
key = str(svo.resolve())
fish_folder = svo.parent.resolve()
fish_output_root = settings.measure_output_root / f"fish{fish_id}"
fish_output_root.mkdir(parents=True, exist_ok=True)
# 生成唯一的 key 列表(用于 processed 标记)
keys = [str(svo.resolve()) for svo in svo_list]
# 检查是否全部已处理
if all(key in processed for key in keys):
return
svo_names = ", ".join(svo.name for svo in svo_list)
logger.info("[measure-watch] batch inference for fish_id={}: {} SVO(s): {}",
fish_id, len(svo_list), svo_names)
logger.info(
"[measure-watch] segment inference fish_id={} svo={}",
fish_id,
svo.name,
)
async with app_state.measure_lock:
app_state.measure_status = "running"
try:
# 使用 batch 模式处理所有 SVO传入 fish_id 作为结果 id
snap = await asyncio.to_thread(
measure_svc.run_full_measure_batch, svo_list, settings, fish_id
def _run():
with app_state.measure_thread_lock:
return measure_svc.run_full_measure(
svo, settings, output_root=fish_output_root
)
snap = await to_thread(_run)
snap = measure_svc.tag_measure_snapshot_meta(
snap,
measurement_phase="segment",
fish_folder=str(fish_folder),
segment_source=str(svo.resolve()),
)
if measure_snapshot_deliverable(snap):
# 保存结果client_id=None 表示对所有客户端可见
# fish_id 只用于 result 中的 id 字段,不作为 client_id
source_paths = "|".join(keys) # 合并所有 source_path
save_measure_snapshot(
settings, snap, source_path=source_paths, client_id=None
settings,
snap,
source_path=str(svo.resolve()),
client_id=None,
)
else:
logger.warning(
"[measure-watch] no deliverable measure rows for fish_id={}, skip SQLite",
"[measure-watch] no deliverable measure rows for fish_id={} svo={}, skip SQLite",
fish_id,
svo.name,
)
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 {}
logger.info(
"[measure-watch] segment done: fish_id={} svo={} weight={!r}",
fish_id,
svo.name,
r0.get("weight", ""),
)
except (RuntimeError, FileNotFoundError) as e:
logger.warning(
"[measure-watch] measure failed fish_id={} svo={}: {}",
fish_id,
svo.name,
e,
)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
except Exception as e:
logger.exception(
"[measure-watch] error fish_id={} svo={}: {}",
fish_id,
svo.name,
e,
)
app_state.measure_status = "idle"
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
async def _run_final_aggregate(
svo_list: List[Path],
fish_id: str,
settings: Settings,
processed: Set[str],
state_file: Path,
final_key: str,
) -> None:
fish_folder = svo_list[0].parent.resolve()
logger.info(
"[measure-watch] final aggregate fish_id={} {} segment(s)",
fish_id,
len(svo_list),
)
async with app_state.measure_lock:
app_state.measure_status = "running"
try:
def _reload():
return measure_svc.reload_segment_snapshots_for_aggregate(
svo_list, fish_id, settings
)
pairs = await to_thread(_reload)
contributing_svos = [p[0] for p in pairs]
segments = [p[1] for p in pairs]
paths_joined = "|".join(sorted(str(p.resolve()) for p in contributing_svos))
snap = measure_svc.build_measure_snapshot_aggregate(
segments,
fish_id,
settings,
contributing_svos=contributing_svos,
fish_folder=str(fish_folder),
segment_source_paths=paths_joined,
)
if measure_snapshot_deliverable(snap):
try:
v_left, v_right = await to_thread(
measure_svc.generate_aggregate_preview_media,
contributing_svos,
snap,
fish_id,
settings,
final_key=final_key,
)
snap.video_left = v_left
snap.video_right = v_right
except Exception as e:
logger.warning(
"[measure-watch] final preview generate failed fish_id={}: {}",
fish_id,
e,
)
save_measure_snapshot(
settings,
snap,
source_path=f"aggregate:{final_key}",
client_id=None,
)
else:
logger.warning(
"[measure-watch] final not deliverable for fish_id={}, skip SQLite",
fish_id,
)
app_state.measure_status = "idle"
# 标记所有 SVO 为已处理
for key in keys:
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
processed.add(final_key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, final_key, "measure")
r0 = snap.result[0] if snap.result else {}
w = r0.get("weight", "")
logger.info(
"[measure-watch] done: fish_id={} SVOs={} weight={!r}",
fish_id, len(svo_list), w
"[measure-watch] final done: fish_id={} weight={!r}",
fish_id,
r0.get("weight", ""),
)
except (RuntimeError, FileNotFoundError) as e:
logger.warning("[measure-watch] measure failed for fish_id={}: {}", fish_id, e)
app_state.measure_status = "idle"
for key in keys:
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
except Exception as e:
logger.exception("[measure-watch] error on fish_id={}: {}", fish_id, e)
logger.exception(
"[measure-watch] final aggregate failed fish_id={}: {}",
fish_id,
e,
)
app_state.measure_status = "idle"
for key in keys:
processed.add(key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, key, "measure")
processed.add(final_key)
if settings.measure_watch_use_state_file:
add_watch_processed(settings, final_key, "measure")
async def watch_tick(
settings: Settings,
processed: Set[str],
stability: Dict[str, tuple[int, int]],
stability: Dict[str, Tuple[int, int]],
final_stability: Dict[str, Tuple[Tuple[Tuple[str, int], ...], int]],
state_file: Path,
) -> bool:
"""处理一轮目录扫描;若处理了至少一个文件返回 True。
使用 batch 模式同一条鱼fish{N} 文件夹)下的所有 SVO 文件会被一起处理,
点云合并后进行重量预测(与 test_dgcnn.sh --batch-root 相同的逻辑)。
"""
"""逐段稳定即测量;同一 fish 目录全部段已处理且整体稳定后写 final。"""
assert settings.measure_watch_dir is not None
watch_dir = settings.measure_watch_dir
did = False
seen_keys: Set[str] = set()
# 使用新的子文件夹扫描方式,返回 (svo_list, fish_id)
for svo_list, fish_id in iter_svo2_folders(watch_dir):
if not svo_list:
continue
# 为该 fish 文件夹中的所有 SVO 文件计算稳定性
# 只有当所有 SVO 都达到稳定轮询次数时才处理
all_stable = True
any_new = False
fish_folder = svo_list[0].parent
folder_key = str(fish_folder.resolve())
for svo in svo_list:
key = str(svo.resolve())
@@ -174,43 +290,57 @@ async def watch_tick(
if key in processed:
continue
any_new = True
try:
st = svo.stat()
except OSError:
all_stable = False
continue
size = int(st.st_size)
if size <= 0:
stability.pop(key, None)
all_stable = False
continue
last = stability.get(key)
if last is None or last[0] != size:
stability[key] = (size, 1)
all_stable = False
else:
_, cnt = last
cnt += 1
stability[key] = (size, cnt)
if cnt < settings.measure_watch_stable_polls:
all_stable = False
if cnt >= settings.measure_watch_stable_polls:
await _run_single_svo_measure(
svo, fish_id, settings, processed, state_file
)
stability.pop(key, None)
did = True
# 如果该文件夹下有新的 SVO 文件且全部达到稳定,则批量处理
if any_new and all_stable:
await _run_measure_and_state(svo_list, fish_id, settings, processed, state_file)
fk = _final_processed_key(fish_id, svo_list)
if fk in processed:
continue
# 清理已处理的 SVO 文件的稳定性记录
for svo in svo_list:
key = str(svo.resolve())
stability.pop(key, None)
if not all(str(p.resolve()) in processed for p in svo_list):
final_stability.pop(folder_key, None)
continue
did = True
tup = _folder_size_tuple(svo_list)
if not tup:
continue
prev = final_stability.get(folder_key)
if prev is None or prev[0] != tup:
final_stability[folder_key] = (tup, 1)
else:
_, c = prev
c += 1
final_stability[folder_key] = (tup, c)
if c >= settings.measure_watch_stable_polls:
await _run_final_aggregate(
svo_list, fish_id, settings, processed, state_file, fk
)
final_stability.pop(folder_key, None)
did = True
# 清理不再看到的文件的稳定性记录
for k in list(stability.keys()):
if k not in seen_keys:
del stability[k]
@@ -231,20 +361,24 @@ async def run_measure_watch_loop(settings: Settings) -> None:
if settings.measure_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
stability = {} # type: Dict[str, Tuple[int, int]]
final_stability = {} # type: Dict[str, Tuple[Tuple[Tuple[str, int], ...], int]]
logger.info(
"[measure-watch] watching {} (poll={}s, stable_polls={}, state={} {})",
"[measure-watch] watching {} (poll={}s, stable_polls={}, aggregate={}, state={} {})",
wd,
settings.measure_watch_poll_interval,
settings.measure_watch_stable_polls,
settings.measure_final_aggregate_mode,
"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)
did = await watch_tick(
settings, processed, stability, final_stability, state_file
)
maybe_warn_idle_watch(
did_work=did,
log_tag="measure-watch",