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

@@ -6,7 +6,7 @@ import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import List
from typing import List, Tuple
from app.logging_config import format_json_pretty
from app.services.video_slice import get_video_duration, slice_video
@@ -102,7 +102,7 @@ def run_action_subprocess(mp4_path: Path, settings: Settings) -> str:
Path(out_json).unlink(missing_ok=True)
def run_full_action(mp4_path: Path, settings: Settings) -> tuple[HealthSnapshot, list[HealthSnapshot]]:
def run_full_action(mp4_path: Path, settings: Settings) -> Tuple[HealthSnapshot, List[HealthSnapshot]]:
"""运行 FishAction 健康检测。如果视频较长,会自动切片后分别检测。
每个切片被视为独立的视频,返回所有切片的结果列表。
@@ -112,7 +112,7 @@ def run_full_action(mp4_path: Path, settings: Settings) -> tuple[HealthSnapshot,
settings: 应用配置
Returns:
tuple[HealthSnapshot, list[HealthSnapshot]]: (第一个切片/完整视频的快照, 所有切片快照列表)
Tuple[HealthSnapshot, List[HealthSnapshot]]: (第一个切片/完整视频的快照, 所有切片快照列表)
- 如果视频被切片:返回 (第一个切片, 所有切片列表)
- 如果视频未被切片:返回 (完整视频快照, [完整视频快照])
"""
@@ -141,7 +141,7 @@ def run_full_action(mp4_path: Path, settings: Settings) -> tuple[HealthSnapshot,
)
# 处理每个切片
all_snaps: list[HealthSnapshot] = []
all_snaps = [] # type: List[HealthSnapshot]
for i, slice_file in enumerate(slice_files):
start_time = i * DEFAULT_SLICE_DURATION
end_time = min(start_time + DEFAULT_SLICE_DURATION, duration)

View File

@@ -2,10 +2,12 @@ from __future__ import annotations
import asyncio
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,
health_snapshot_deliverable,
@@ -28,7 +30,7 @@ def _state_path(settings: Settings) -> Path:
return settings.action_watch_dir / ".fishaction_watch_processed.json"
def iter_mp4(watch_dir: Path, recursive: bool) -> list[Path]:
def iter_mp4(watch_dir: Path, recursive: bool) -> List[Path]:
if recursive:
return sorted(
p
@@ -56,7 +58,7 @@ async def _run_inference_and_state(
app_state.action_status = "running"
try:
# 返回 (第一个快照, 所有切片快照列表)
first_snap, all_snaps = await asyncio.to_thread(
first_snap, all_snaps = await to_thread(
action_svc.run_full_action, mp4, settings
)
@@ -98,7 +100,7 @@ async def _run_inference_and_state(
async def watch_tick(
settings: Settings,
processed: Set[str],
stability: Dict[str, tuple[int, int]],
stability: Dict[str, Tuple[int, int]],
state_file: Path,
) -> bool:
"""处理一轮目录扫描;若处理了至少一个文件返回 True。"""
@@ -149,7 +151,7 @@ async def run_action_watch_loop(settings: Settings) -> None:
if settings.action_watch_use_state_file
else set()
)
stability: Dict[str, tuple[int, int]] = {}
stability = {} # type: Dict[str, Tuple[int, int]]
logger.info(
"[action-watch] watching {} (poll={}s, stable_polls={}, state={} {})",

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import json
import math
import os
import statistics
import re
import shutil
import subprocess
@@ -942,13 +943,208 @@ def _find_preview_videos(output_dir: Path) -> Tuple[Optional[Path], Optional[Pat
return None, None
def _snapshot_length_mm(snap: MeasureSnapshot) -> Optional[float]:
if snap.result and isinstance(snap.result[0], dict):
return _row_finite_field(snap.result[0], "length")
return None
def _materialize_weight_json_for_svo(
svo_path: Path,
settings: Settings,
*,
fish_id: str,
temp_dir: Path,
) -> Path:
root = settings.measure_output_root / f"fish{fish_id}"
candidate = root / svo_path.stem / "weight_prediction.json"
if candidate.is_file():
return candidate
data = _load_weight_json(svo_path, settings, output_root=root)
temp_dir.mkdir(parents=True, exist_ok=True)
materialized = temp_dir / f"{svo_path.stem}_weight_prediction.json"
materialized.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return materialized
def _run_generate_video_with_labels_cli(
*,
settings: Settings,
svo_path: Path,
output_dir: Path,
weight_json: Path,
summary_weight_g: Optional[float],
summary_length_mm: Optional[float],
summary_star: bool,
output_video_name: str,
) -> Path:
script = settings.fish_measure_root / "generate_video_with_labels.py"
if not script.is_file():
raise FileNotFoundError(f"Missing FishMeasure preview script: {script}")
output_dir.mkdir(parents=True, exist_ok=True)
cmd = [
_py_exe(settings),
str(script),
"--svo",
str(svo_path.resolve()),
"--save-output",
str(output_dir.resolve()),
"--weight-json",
str(weight_json.resolve()),
"--conf",
str(settings.measure_yolo_conf),
"--sam-device",
settings.sam_device,
"--output-video-name",
output_video_name,
]
if settings.predict_show_large_labels_at_top_right:
cmd.append("--show-large-labels-at-top-right")
if summary_weight_g is not None and math.isfinite(summary_weight_g):
cmd.extend(["--summary-weight-g", str(float(summary_weight_g))])
if summary_length_mm is not None and math.isfinite(summary_length_mm):
cmd.extend(["--summary-length-mm", str(float(summary_length_mm))])
if summary_star:
cmd.append("--summary-star")
proc = run_subprocess_with_log(
cmd,
cwd=str(settings.fish_measure_root),
env=os.environ.copy(),
log_name="FishMeasure",
stream_to_logger=False,
)
if proc.returncode != 0:
err = proc.stdout or ""
raise RuntimeError(
f"generate_video_with_labels.py failed ({proc.returncode}): {err[-4000:]}"
)
video_path = output_dir / "images" / output_video_name
if not video_path.is_file():
raise FileNotFoundError(f"Expected generated preview video at {video_path}")
return video_path
def _concat_preview_videos(inputs: List[Path], dst: Path) -> bool:
if not inputs:
return False
try:
import cv2
except Exception:
return False
writer = None
target_size: Optional[Tuple[int, int]] = None
fps = 10.0
wrote_any = False
try:
dst.parent.mkdir(parents=True, exist_ok=True)
for src in inputs:
if not src.is_file():
continue
cap = cv2.VideoCapture(str(src))
if not cap.isOpened():
continue
cur_fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if width <= 0 or height <= 0:
cap.release()
continue
if writer is None:
target_size = (width, height)
fps = cur_fps if cur_fps > 0.0 else 10.0
writer = cv2.VideoWriter(
str(dst),
cv2.VideoWriter_fourcc(*"mp4v"),
fps,
target_size,
)
while True:
ok, frame = cap.read()
if not ok:
break
if target_size is not None and (
frame.shape[1] != target_size[0] or frame.shape[0] != target_size[1]
):
frame = cv2.resize(frame, target_size)
writer.write(frame)
wrote_any = True
cap.release()
finally:
if writer is not None:
writer.release()
return wrote_any and dst.is_file() and dst.stat().st_size > 0
def generate_aggregate_preview_media(
contributing_svos: List[Path],
snap: MeasureSnapshot,
fish_id: str,
settings: Settings,
*,
final_key: str,
) -> Tuple[str, str]:
"""为 final 快照生成并发布串接预览视频,返回 (video_left, video_right)。"""
if not contributing_svos or not snap.result:
return "", ""
summary_weight_g = snap.pred if snap.pred is not None and math.isfinite(snap.pred) else None
summary_length_mm = _snapshot_length_mm(snap)
root = settings.measure_output_root / f"fish{fish_id}"
temp_root = root / "__aggregate_preview__" / _safe_media_prefix(final_key)
temp_root.mkdir(parents=True, exist_ok=True)
part_videos: List[Path] = []
weight_json_cache_dir = temp_root / "__weight_json_cache__"
for idx, svo in enumerate(contributing_svos, start=1):
part_dir = temp_root / f"part_{idx:02d}_{_safe_media_prefix(svo.stem)}"
weight_json = _materialize_weight_json_for_svo(
svo,
settings,
fish_id=fish_id,
temp_dir=weight_json_cache_dir,
)
part_video = _run_generate_video_with_labels_cli(
settings=settings,
svo_path=svo,
output_dir=part_dir,
weight_json=weight_json,
summary_weight_g=summary_weight_g,
summary_length_mm=summary_length_mm,
summary_star=bool(snap.star),
output_video_name=f"final_part_{idx:02d}.mp4",
)
part_videos.append(part_video)
if not part_videos:
return "", ""
final_preview = temp_root / "final_preview.mp4"
if not _concat_preview_videos(part_videos, final_preview):
raise RuntimeError(f"Failed to concatenate final preview videos into {final_preview}")
return _publish_media(
final_preview,
final_preview,
settings,
f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}_final_fish{fish_id}_{_safe_media_prefix(final_key)}",
)
def _split_sbs_video(src: Path, left_dst: Path, right_dst: Path) -> bool:
"""Split a side-by-side stereo video (W x H where W == 2*H_single) into left/right halves.
Returns True if split succeeded, False otherwise (caller should fall back to copy).
"""
ffmpeg_path = _get_ffmpeg_path()
ffprobe_path = str(Path(ffmpeg_path).parent / "ffprobe")
from app.services.video_slice import _get_ffprobe_path
ffprobe_path = _get_ffprobe_path()
probe = subprocess.run(
[
ffprobe_path, "-v", "quiet", "-print_format", "json",
@@ -990,21 +1186,19 @@ def _split_sbs_video(src: Path, left_dst: Path, right_dst: Path) -> bool:
def _get_ffmpeg_path() -> str:
"""获取可用的 ffmpeg 路径。优先使用项目配置的 ffmpeg"""
# 优先使用项目目录下的 ffmpeg
project_ffmpeg = Path("/home/ubuntu/projects/FishServer/tools/ffmpeg/bin/ffmpeg")
if project_ffmpeg.is_file():
return str(project_ffmpeg)
# 尝试系统路径
"""获取可用的 ffmpeg 路径。优先使用 .env 中 FFMPEG_PATH 配置"""
from app.settings import get_settings
configured = get_settings().ffmpeg_path.strip()
if configured and Path(configured).is_file():
return configured
system_paths = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"]
for path in system_paths:
if Path(path).is_file():
return path
# 回退到 PATH 中的 ffmpeg
return "ffmpeg"
def _get_h264_encoder() -> tuple[str, list[str], str]:
def _get_h264_encoder() -> Tuple[str, List[str], str]:
"""检测可用的H.264编码器,返回 (encoder_name, options, ffmpeg_path)。
优先使用 libx264纯软件最可靠硬件编码器需要实际测试才能确认可用。
@@ -1062,9 +1256,10 @@ def _transcode_with_x264(src: Path, dst: Path) -> bool:
return False
# 首先用 ffprobe 获取视频信息
from app.services.video_slice import _get_ffprobe_path
try:
probe = subprocess.run(
["ffprobe", "-v", "quiet", "-print_format", "json", "-show_streams", str(src)],
[_get_ffprobe_path(), "-v", "quiet", "-print_format", "json", "-show_streams", str(src)],
capture_output=True, text=True, timeout=10
)
import json as _json
@@ -1373,7 +1568,7 @@ def build_measure_snapshot(
updated_at=datetime.now(timezone.utc),
raw_prediction_path=str(root / svo_path.stem / "weight_prediction.json"),
pred=pred_weight,
star=is_confident,
star=False,
calculation_log=calc_log,
)
@@ -1537,6 +1732,209 @@ def build_measure_snapshot_batch(
updated_at=datetime.now(timezone.utc),
raw_prediction_path=str(combined_json) if combined_json.is_file() else None,
pred=pred_weight,
star=is_confident,
star=False,
calculation_log=calc_log,
)
def _row_finite_field(d: dict, key: str) -> Optional[float]:
v = d.get(key)
if v is None:
return None
try:
x = float(v)
return x if math.isfinite(x) else None
except (TypeError, ValueError):
return None
def tag_measure_snapshot_meta(
snap: MeasureSnapshot,
*,
measurement_phase: str,
fish_folder: str = "",
segment_source: str = "",
) -> MeasureSnapshot:
"""仅在 ``calculation_log`` 中记录 segment/final 元信息;不向 ``result`` 行追加字段(保持 biomass 接口约定)。"""
lines = [
f"# measurement_phase: {measurement_phase}",
f"# fish_folder: {fish_folder}",
]
if segment_source:
lines.append(f"# segment_source: {segment_source}")
meta = "\n".join(lines) + "\n"
snap.calculation_log = meta + (snap.calculation_log or "")
return snap
def _segment_weight_g(snap: MeasureSnapshot) -> Optional[float]:
if snap.pred is not None and math.isfinite(snap.pred):
return float(snap.pred)
if snap.result and isinstance(snap.result[0], dict):
return _row_finite_field(snap.result[0], "weight")
return None
def _segment_length_mm(snap: MeasureSnapshot) -> Optional[float]:
if snap.result and isinstance(snap.result[0], dict):
return _row_finite_field(snap.result[0], "length")
return None
def _aggregate_numeric(values: List[float], mode: str) -> float:
if not values:
return float("nan")
m = (mode or "median").strip().lower()
if m not in ("median", "mean", "trimmed_mean"):
m = "median"
if m == "mean":
return float(sum(values) / len(values))
if m == "trimmed_mean" and len(values) >= 3:
s = sorted(values)
core = s[1:-1]
return float(sum(core) / len(core))
return float(statistics.median(values))
def _final_star_from_contributing_svos(
contributing_svos: List[Path], fish_id: str, settings: Settings
) -> bool:
"""与逐段曾用 ``all(s.star)`` 等价:每段对 dgcnn_summary 做 ``_star_confident_like_test_dgcnn``。"""
if not contributing_svos:
return False
root = settings.measure_output_root / f"fish{fish_id}"
for svo in contributing_svos:
try:
data = _load_weight_json(svo, settings, output_root=root)
except (FileNotFoundError, RuntimeError, OSError, ValueError):
return False
summary = data.get("dgcnn_summary") or data.get("weight_summary") or {}
if not _star_confident_like_test_dgcnn(summary):
return False
return True
def reload_segment_snapshots_for_aggregate(
svo_list: List[Path], fish_id: str, settings: Settings
) -> List[Tuple[Path, MeasureSnapshot]]:
"""从已写入的 FishMeasure 输出目录重载各段快照(用于齐套 final 聚合,无需重跑子进程)。
逐段测量失败或未写出 ``weight_prediction.json`` 的段仍会被 watch 标为已处理;
此处跳过这些段,仅用成功段聚合,避免 final 整批因单段缺文件而失败。
返回 ``(svo_path, snapshot)`` 列表,与 ``build_measure_snapshot_aggregate(..., contributing_svos=...)`` 对齐。
"""
root = settings.measure_output_root / f"fish{fish_id}"
out: List[Tuple[Path, MeasureSnapshot]] = []
for svo in sorted(svo_list, key=lambda x: str(x.resolve())):
try:
out.append((svo, build_measure_snapshot(svo, settings, output_root=root)))
except (FileNotFoundError, RuntimeError) as e:
logger.warning(
"[FishMeasure] final aggregate skip segment (no usable weight JSON) fish_id={} svo={}: {}",
fish_id,
svo.name,
e,
)
return out
def build_measure_snapshot_aggregate(
segments: List[MeasureSnapshot],
fish_id: str,
settings: Settings,
*,
contributing_svos: List[Path],
fish_folder: str = "",
segment_source_paths: str = "",
) -> MeasureSnapshot:
"""从逐段 ``MeasureSnapshot`` 聚合为一条 final 快照(无预览视频)。
``contributing_svos`` 须与 ``segments`` 等长且顺序一一对应(成功重载的各段 SVO
用于按段 ``dgcnn_summary`` 计算 final ``star``。
"""
if not segments:
return MeasureSnapshot(
result=[],
video_left="",
video_right="",
error="no_segments",
)
if len(contributing_svos) != len(segments):
raise ValueError(
f"contributing_svos length {len(contributing_svos)} != segments length {len(segments)}"
)
mode = (settings.measure_final_aggregate_mode or "median").strip().lower()
if mode not in ("median", "mean", "trimmed_mean"):
mode = "median"
ws: List[float] = [w for s in segments if (w := _segment_weight_g(s)) is not None]
ls = [ln for s in segments if (ln := _segment_length_mm(s)) is not None]
star_final = _final_star_from_contributing_svos(contributing_svos, fish_id, settings)
if not ws:
return MeasureSnapshot(
result=[],
video_left="",
video_right="",
error="aggregate_no_segment_weights",
calculation_log="# measurement_phase: final\n# error: no segment weights\n",
)
pred_w = _aggregate_numeric(ws, mode)
pred_len = _aggregate_numeric(ls, mode) if ls else float("nan")
if not math.isfinite(pred_w):
for s in segments:
w = _segment_weight_g(s)
if w is not None and math.isfinite(w):
pred_w = w
break
if not math.isfinite(pred_len):
for s in segments:
ln = _segment_length_mm(s)
if ln is not None and math.isfinite(ln):
pred_len = ln
break
fid = int(fish_id) if fish_id.isdigit() else 1
fish_type = "大黄鱼"
if segments[0].result and isinstance(segments[0].result[0], dict):
t = segments[0].result[0].get("type")
if isinstance(t, str) and t.strip():
fish_type = t.strip()
row: Dict[str, Any] = {
"id": fid,
"type": fish_type,
"weight": str(round(pred_w)) if math.isfinite(pred_w) else "",
"length": str(round(pred_len)) if math.isfinite(pred_len) else "",
"date": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
}
result = [row]
lines = [
f"# aggregate_mode: {mode}",
f"# segment_count: {len(segments)}",
f"# segment_weights_g: {ws}",
f"# segment_lengths_mm: {ls}",
]
calc_log = "\n".join(lines) + "\n# Aggregated from per-segment former results (measure_watch).\n"
snap = MeasureSnapshot(
result=result,
video_left="",
video_right="",
updated_at=datetime.now(timezone.utc),
raw_prediction_path=None,
pred=pred_w if math.isfinite(pred_w) else None,
star=star_final,
calculation_log=calc_log,
)
return tag_measure_snapshot_meta(
snap,
measurement_phase="final",
fish_folder=fish_folder,
segment_source=segment_source_paths,
)

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",

View File

@@ -2,11 +2,12 @@ from __future__ import annotations
import uuid
from pathlib import Path
from typing import Tuple
PARTIAL_NAME = "upload.partial"
def new_session_dir(base: Path) -> tuple[str, Path]:
def new_session_dir(base: Path) -> Tuple[str, Path]:
session_id = uuid.uuid4().hex
d = base / session_id
d.mkdir(parents=True, exist_ok=True)

View File

@@ -10,8 +10,12 @@ import asyncio
import shutil
from pathlib import Path
from typing import Optional, Tuple
from loguru import logger
from app.compat import to_thread
from app.services.action_watch import iter_mp4
from app.services.measure import transcode_src_to_h264_dst
from app.settings import Settings
@@ -21,7 +25,7 @@ _publish_lock = asyncio.Lock()
DEFAULT_CLIENT_ID = "default"
# 源路径 + mtime用于跳过重复转码
_last_src_key: tuple[str, float] | None = None
_last_src_key = None # type: Optional[Tuple[str, float]]
_cached_public_url: str = ""
@@ -37,7 +41,7 @@ def _safe_sonar_media_basename(raw: str) -> str:
return Path(n).name or "biomass_sonar.mp4"
def resolve_sonar_video_source(settings: Settings) -> Path | None:
def resolve_sonar_video_source(settings: Settings) -> Optional[Path]:
"""优先 BIOMASS_SONAR_VIDEO_SOURCE否则在 BIOMASS_SONAR_VIDEO_DIR 中取 mtime 最新的 .mp4。"""
cfg = settings.biomass_sonar_video_source
if cfg is not None:
@@ -71,13 +75,13 @@ async def _publish_video(
tmp.unlink(missing_ok=True)
try:
ok = await asyncio.to_thread(transcode_src_to_h264_dst, src, tmp)
ok = await to_thread(transcode_src_to_h264_dst, src, tmp)
if ok and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dst)
logger.info("[sonar-video] published H.264: {} -> {}", src.name, dst.name)
else:
tmp.unlink(missing_ok=True)
await asyncio.to_thread(shutil.copy2, src, dst)
await to_thread(shutil.copy2, src, dst)
logger.warning(
"[sonar-video] transcode failed, copied raw: {} -> {}",
src.name,

View File

@@ -5,16 +5,17 @@ from __future__ import annotations
import subprocess
import tempfile
from pathlib import Path
from typing import List, Tuple
from typing import List, Optional, Tuple
from loguru import logger
def _get_ffmpeg_path() -> str:
"""获取可用的 ffmpeg 路径。"""
project_ffmpeg = Path("/home/ubuntu/projects/FishServer/tools/ffmpeg/bin/ffmpeg")
if project_ffmpeg.is_file():
return str(project_ffmpeg)
"""获取可用的 ffmpeg 路径。优先使用 .env 中 FFMPEG_PATH 配置。"""
from app.settings import get_settings
configured = get_settings().ffmpeg_path.strip()
if configured and Path(configured).is_file():
return configured
system_paths = ["/usr/bin/ffmpeg", "/usr/local/bin/ffmpeg"]
for path in system_paths:
if Path(path).is_file():
@@ -23,11 +24,15 @@ def _get_ffmpeg_path() -> str:
def _get_ffprobe_path() -> str:
"""获取可用的 ffprobe 路径。"""
"""获取可用的 ffprobe 路径。从 ffmpeg 同目录推导,回退到系统 PATH。"""
ffmpeg_path = Path(_get_ffmpeg_path())
ffprobe = ffmpeg_path.parent / "ffprobe"
if ffprobe.is_file():
return str(ffprobe)
if ffmpeg_path.parent.name:
sibling = ffmpeg_path.parent / "ffprobe"
if sibling.is_file():
return str(sibling)
for p in ["/usr/bin/ffprobe", "/usr/local/bin/ffprobe"]:
if Path(p).is_file():
return p
return "ffprobe"
@@ -58,7 +63,7 @@ def get_video_duration(video_path: Path) -> float:
def slice_video(
video_path: Path,
slice_duration: float = 10.0,
output_dir: Path | None = None,
output_dir: Optional[Path] = None,
) -> Tuple[List[Path], Path]:
"""将视频按固定时长切分为多个片段。

View File

@@ -12,10 +12,12 @@ from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from typing import Dict, List
from typing import Dict, List, Optional
from loguru import logger
from app.compat import to_thread
from app.services.action_watch import iter_mp4
from app.services.measure import transcode_src_to_h264_dst
from app.services.video_slice import get_video_duration, slice_video
@@ -60,7 +62,7 @@ _global_slice_urls: List[str] = []
_last_source_mtime: float = 0.0
def resolve_water_video_source(settings: Settings) -> Path | None:
def resolve_water_video_source(settings: Settings) -> Optional[Path]:
"""优先 BIOMASS_WATER_VIDEO_SOURCE否则取 ACTION_WATCH_DIR 中 mtime 最新的 .mp4。"""
cfg = settings.biomass_water_video_source
if cfg is not None:
@@ -103,13 +105,13 @@ async def _publish_video(
tmp.unlink(missing_ok=True)
try:
ok = await asyncio.to_thread(transcode_src_to_h264_dst, src, tmp)
ok = await to_thread(transcode_src_to_h264_dst, src, tmp)
if ok and tmp.is_file() and tmp.stat().st_size > 0:
tmp.replace(dst)
logger.info("[water-video] published H.264: {} -> {}", src.name, dst.name)
else:
tmp.unlink(missing_ok=True)
await asyncio.to_thread(shutil.copy2, src, dst)
await to_thread(shutil.copy2, src, dst)
logger.warning(
"[water-video] transcode failed, copied raw: {} -> {}",
src.name,

View File

@@ -0,0 +1,132 @@
"""ZED 分段录制线程的启动/停止(供 lifespan 与 HTTP API 共用)。"""
from __future__ import annotations
import threading
from typing import Optional, Tuple
from loguru import logger
from app.db import (
begin_zed_recording_session,
mark_zed_recording_session_stopped,
)
from app.services.zed_svo_record import run_zed_svo_record_loop
from app.settings import Settings
from app.state import app_state
def _cleanup_stale_zed_thread() -> None:
t = app_state.zed_recording_thread
if t is not None and not t.is_alive():
sid = app_state.zed_recording_session_row_id
app_state.zed_recording_thread = None
app_state.zed_recording_stop_event = None
if sid is not None:
try:
from app.settings import get_settings
mark_zed_recording_session_stopped(get_settings(), sid)
except Exception as e:
logger.warning("[zed-svo] 线程已退出但写库失败: {}", e)
app_state.zed_recording_session_row_id = None
app_state.zed_recording_fish_id = None
def start_zed_recording(
settings: Settings,
*,
segment_sec: Optional[float] = None,
) -> Tuple[bool, str, Optional[int], Optional[int]]:
"""启动后台录制线程;已在运行则 ``(False, 'already_running', None, None)``。
每次启动在 SQLite ``zed_recording_sessions`` 中分配新 ``fish_id`` 并写入输出目录。
成功时返回 ``(True, 'started', fish_id, session_row_id)``。
"""
with app_state.zed_recording_lock:
_cleanup_stale_zed_thread()
if (
app_state.zed_recording_thread is not None
and app_state.zed_recording_thread.is_alive()
):
return False, "already_running", None, None
try:
session_row_id, fish_id, resolved = begin_zed_recording_session(settings)
except Exception as e:
logger.exception("[zed-svo] 分配会话失败")
return False, f"session_db_error:{e}", None, None
stop = threading.Event()
def _run() -> None:
run_zed_svo_record_loop(
settings,
stop,
output_dir=resolved,
segment_sec=segment_sec,
)
th = threading.Thread(
target=_run,
name="zed-svo-record",
daemon=True,
)
app_state.zed_recording_stop_event = stop
app_state.zed_recording_thread = th
app_state.zed_recording_session_row_id = session_row_id
app_state.zed_recording_fish_id = fish_id
th.start()
logger.info(
"[zed-svo] 录制线程已启动 session_id={} fish_id={} output_dir={}",
session_row_id,
fish_id,
resolved,
)
return True, "started", fish_id, session_row_id
def stop_zed_recording(
settings: Settings, timeout_sec: float = 30.0
) -> Tuple[bool, str, Optional[int]]:
"""请求停止并 ``join`` 录制线程;成功停止后更新数据库并返回 ``fish_id``。"""
session_row_id: Optional[int] = None
with app_state.zed_recording_lock:
_cleanup_stale_zed_thread()
if app_state.zed_recording_thread is None:
return False, "not_running", None
if not app_state.zed_recording_thread.is_alive():
app_state.zed_recording_thread = None
app_state.zed_recording_stop_event = None
app_state.zed_recording_session_row_id = None
app_state.zed_recording_fish_id = None
return False, "not_running", None
session_row_id = app_state.zed_recording_session_row_id
ev = app_state.zed_recording_stop_event
th = app_state.zed_recording_thread
if ev is not None:
ev.set()
if th is not None:
th.join(timeout=timeout_sec)
if th.is_alive():
logger.warning("[zed-svo] stop join 超时,线程仍在运行")
return False, "stop_timeout", None
app_state.zed_recording_stop_event = None
app_state.zed_recording_thread = None
app_state.zed_recording_session_row_id = None
app_state.zed_recording_fish_id = None
logger.info("[zed-svo] 录制线程已停止")
fish_id: Optional[int] = None
if session_row_id is not None:
fish_id = mark_zed_recording_session_stopped(settings, session_row_id)
return True, "stopped", fish_id
def zed_recording_is_running() -> bool:
_cleanup_stale_zed_thread()
t = app_state.zed_recording_thread
return t is not None and t.is_alive()

View File

@@ -0,0 +1,109 @@
"""后台线程:单台 ZED 分段录制 .svo2可选依赖 pyzed"""
from __future__ import annotations
import time
from datetime import datetime, timezone
from pathlib import Path
from threading import Event
from typing import Optional
from loguru import logger
from app.settings import Settings
def _try_import_sl():
try:
import pyzed.sl as sl
return sl
except ImportError:
return None
def run_zed_svo_record_loop(
settings: Settings,
stop_event: Event,
*,
output_dir: Optional[Path] = None,
segment_sec: Optional[float] = None,
) -> None:
"""阻塞运行直到 ``stop_event`` 置位;每段按 ``segment_sec`` 或配置轮换输出文件。
``output_dir`` / ``segment_sec`` 非空时覆盖 ``settings`` 中对应逻辑。
"""
sl = _try_import_sl()
if sl is None:
logger.warning(
"[zed-svo] pyzed 不可用,跳过 ZED 分段录制(请安装 ZED SDK / pyzed"
)
return
out_dir = output_dir if output_dir is not None else settings.zed_svo_record_dir
if out_dir is None:
logger.error("[zed-svo] zed_svo_record_dir 未解析,跳过录制")
return
out_dir = out_dir.expanduser().resolve()
out_dir.mkdir(parents=True, exist_ok=True)
zed = sl.Camera()
init = sl.InitParameters()
init.camera_resolution = sl.RESOLUTION.HD720
init.camera_fps = 30
init.depth_mode = sl.DEPTH_MODE.ULTRA
init.coordinate_units = sl.UNIT.MILLIMETER
if settings.zed_serial_number is not None:
init.set_from_serial_number(settings.zed_serial_number)
err_open = zed.open(init)
if err_open != sl.ERROR_CODE.SUCCESS:
logger.error("[zed-svo] 打开相机失败: {}", repr(err_open))
return
runtime = sl.RuntimeParameters()
seg_sec = float(
segment_sec if segment_sec is not None else settings.zed_svo_segment_sec
)
segment_index = 0
try:
while not stop_event.is_set():
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = out_dir / f"zed_{ts}_{segment_index:06d}.svo2"
segment_index += 1
rec = sl.RecordingParameters(
str(out_path.resolve()),
sl.SVO_COMPRESSION_MODE.H264,
)
err_rec = zed.enable_recording(rec)
if err_rec != sl.ERROR_CODE.SUCCESS:
logger.error(
"[zed-svo] enable_recording 失败: {} path={}",
repr(err_rec),
out_path,
)
return
logger.info("[zed-svo] 开始写入 {}", out_path.name)
deadline = time.monotonic() + seg_sec
while time.monotonic() < deadline and not stop_event.is_set():
err = zed.grab(runtime)
if err != sl.ERROR_CODE.SUCCESS:
if err == sl.ERROR_CODE.END_OF_SVOFILE_REACHED:
break
logger.warning("[zed-svo] grab 非成功: {}", repr(err))
zed.disable_recording()
logger.info("[zed-svo] 结束一段 {}", out_path.name)
finally:
try:
zed.disable_recording()
except Exception as e:
logger.debug("[zed-svo] disable_recording (finally): {}", e)
zed.close()
logger.info("[zed-svo] 相机已关闭")