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:
@@ -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)
|
||||
|
||||
@@ -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={} {})",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]:
|
||||
"""将视频按固定时长切分为多个片段。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
132
fish_api/app/services/zed_recording_control.py
Normal file
132
fish_api/app/services/zed_recording_control.py
Normal 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()
|
||||
109
fish_api/app/services/zed_svo_record.py
Normal file
109
fish_api/app/services/zed_svo_record.py
Normal 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] 相机已关闭")
|
||||
Reference in New Issue
Block a user