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

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

259 lines
8.5 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from app.logging_config import format_json_pretty
from app.settings import Settings
from app.state import MeasureSnapshot
from app.subprocess_run import run_subprocess_with_log
from loguru import logger
def _py_exe(settings: Settings) -> str:
return settings.python_fish_measure or sys.executable
def _predict_weigth_from_svo2_extra_args(settings: Settings) -> List[str]:
"""Flags aligned with FishMeasure/predict_weigth_from_svo2.py CLI."""
out: List[str] = []
if settings.predict_filter_pointcloud:
out.append("--filter-pointcloud")
if settings.predict_use_density_filter:
out.append("--use-density-filter")
if settings.predict_use_clustering_filter:
out.append("--use-clustering-filter")
if (
settings.predict_use_pointcloud_classifier
and settings.predict_pointcloud_classifier
and Path(settings.predict_pointcloud_classifier).is_file()
):
out.extend(
[
"--pointcloud-classifier",
settings.predict_pointcloud_classifier,
"--use-pointcloud-classifier",
"--pointcloud-classifier-threshold",
str(settings.predict_pointcloud_classifier_threshold),
]
)
if settings.predict_use_flatness_filter:
out.append("--use-flatness-filter")
out.extend(["--flatness-threshold", str(settings.predict_flatness_threshold)])
out.extend(["--weight-top-k", str(settings.measure_weight_top_k)])
if settings.measure_weight_top_by_length:
out.append("--weight-top-by-length")
else:
out.append("--no-weight-top-by-length")
if settings.predict_fish_video_weight_overlay:
out.extend(
[
"--fish-video-weight-overlay",
"--minute-interval-sec",
str(settings.predict_minute_interval_sec),
]
)
return out
def run_measure_subprocess(svo_path: Path, settings: Settings) -> None:
script = settings.fish_measure_root / "predict_weigth_from_svo2.py"
if not script.is_file():
raise FileNotFoundError(f"Missing FishMeasure script: {script}")
settings.measure_output_root.mkdir(parents=True, exist_ok=True)
cmd = [
_py_exe(settings),
str(script),
"--svo",
str(svo_path.resolve()),
"--save-output",
str(settings.measure_output_root.resolve()),
"--yolo-model",
settings.yolo_model,
"--weight-checkpoint",
settings.weight_checkpoint,
"--conf",
str(settings.predict_conf),
"--imgsz",
str(settings.predict_imgsz),
"--sam-device",
settings.sam_device,
"--max-frames",
str(settings.predict_max_frames),
"--frame-stride",
str(settings.predict_frame_stride),
]
cmd.extend(_predict_weigth_from_svo2_extra_args(settings))
proc = run_subprocess_with_log(
cmd,
cwd=str(settings.fish_measure_root),
env=os.environ.copy(),
log_name="FishMeasure",
)
if proc.returncode != 0:
err = proc.stdout or ""
raise RuntimeError(
f"predict_weigth_from_svo2.py failed ({proc.returncode}): {err[-4000:]}"
)
def _load_weight_json(svo_path: Path, settings: Settings) -> Dict[str, Any]:
stem = svo_path.stem
candidate = settings.measure_output_root / stem / "weight_prediction.json"
if not candidate.is_file():
raise FileNotFoundError(f"Expected output missing: {candidate}")
with open(candidate, encoding="utf-8") as f:
return json.load(f)
def _find_preview_videos(output_dir: Path) -> Tuple[Optional[Path], Optional[Path]]:
previews = sorted(output_dir.rglob("*preview*.mp4"))
if len(previews) >= 2:
return previews[0], previews[1]
all_mp4 = sorted(output_dir.rglob("*.mp4"))
if len(all_mp4) >= 2:
return all_mp4[0], all_mp4[1]
if len(all_mp4) == 1:
return all_mp4[0], all_mp4[0]
if len(previews) == 1:
return previews[0], previews[0]
return None, None
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).
"""
probe = subprocess.run(
[
"ffprobe", "-v", "quiet", "-print_format", "json",
"-show_streams", str(src),
],
capture_output=True, text=True,
)
if probe.returncode != 0:
return False
import json as _json
try:
streams = _json.loads(probe.stdout).get("streams", [])
vstream = next((s for s in streams if s.get("codec_type") == "video"), None)
if vstream is None:
return False
w, h = int(vstream["width"]), int(vstream["height"])
except Exception:
return False
half_w = w // 2
if half_w < 1 or w < h:
return False
for crop, dst in [
(f"crop={half_w}:{h}:{half_w}:0", left_dst),
(f"crop={half_w}:{h}:0:0", right_dst),
]:
r = subprocess.run(
["ffmpeg", "-y", "-i", str(src), "-vf", crop, "-an", "-q:v", "5", str(dst)],
capture_output=True, text=True,
)
if r.returncode != 0:
return False
return True
def _publish_media(
left: Optional[Path],
right: Optional[Path],
settings: Settings,
) -> Tuple[str, str]:
settings.media_root.mkdir(parents=True, exist_ok=True)
left_dst = settings.media_root / "latest_left.mp4"
right_dst = settings.media_root / "latest_right.mp4"
base = settings.public_base_url.rstrip("/")
if left is not None and left == right and left.is_file():
if _split_sbs_video(left, left_dst, right_dst):
return (
f"{base}/media/{left_dst.name}",
f"{base}/media/{right_dst.name}",
)
def publish(src: Optional[Path], dst: Path) -> str:
if src is None or not src.is_file():
return ""
shutil.copy2(src, dst)
return f"{base}/media/{dst.name}"
vl = publish(left, left_dst)
vr = publish(right, right_dst)
return vl, vr
def build_measure_snapshot(svo_path: Path, settings: Settings) -> MeasureSnapshot:
data = _load_weight_json(svo_path, settings)
summary = data.get("dgcnn_summary") or data.get("weight_summary") or {}
length_mm = summary.get("avg_length_input")
weight_g = summary.get("avg_predicted_weight_g")
if length_mm is None:
length_mm = data.get("avg_length_input")
if weight_g is None:
weight_g = data.get("avg_predicted_weight_g")
today = date.today().isoformat()
result_item = {
"id": 1,
"type": settings.default_fish_species,
"length": "" if length_mm is None else str(int(round(float(length_mm)))),
"weight": "" if weight_g is None else str(int(round(float(weight_g)))),
"date": today,
}
logger.info(
"[FishMeasure] parsed {}\navg_length_mm={} avg_weight_g={}\nweight_summary:\n{}",
svo_path.name,
length_mm,
weight_g,
format_json_pretty(summary if summary else {}),
)
logger.info(
"[FishMeasure] API result_item:\n{}",
format_json_pretty(result_item),
)
out_dir = Path(data.get("output_dir", settings.measure_output_root / svo_path.stem))
lv, rv = _find_preview_videos(out_dir)
v_left, v_right = _publish_media(lv, rv, settings)
logger.info(
"[FishMeasure] media preview_paths={} {} | published_left={} published_right={}",
lv,
rv,
v_left or "(none)",
v_right or "(none)",
)
return MeasureSnapshot(
result=[result_item],
video_left=v_left,
video_right=v_right,
updated_at=datetime.now(timezone.utc),
raw_prediction_path=str(
settings.measure_output_root / svo_path.stem / "weight_prediction.json"
),
)
def run_full_measure(svo_path: Path, settings: Settings) -> MeasureSnapshot:
logger.info("[FishMeasure] start svo={}", svo_path.resolve())
run_measure_subprocess(svo_path, settings)
snap = build_measure_snapshot(svo_path, settings)
logger.info("[FishMeasure] done svo={} result_len={}", svo_path.name, len(snap.result))
return snap