fix video label

This commit is contained in:
zaiun xu
2026-04-10 10:30:01 +08:00
parent e1b514836e
commit 09736f9e15
20 changed files with 518 additions and 268 deletions

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import json
import math
import os
import re
import shutil
import subprocess
import sys
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -59,6 +61,8 @@ def _predict_weigth_from_svo2_extra_args(settings: Settings) -> List[str]:
str(settings.predict_minute_interval_sec),
]
)
if not settings.measure_reuse_existing_clouds:
out.append("--no-reuse-existing-clouds")
return out
@@ -106,13 +110,116 @@ def run_measure_subprocess(svo_path: Path, settings: Settings) -> None:
)
def _load_weight_json(svo_path: Path, settings: Settings) -> Dict[str, Any]:
def _summary_entry_matches_svo(item: Dict[str, Any], svo_path: Path) -> bool:
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)
resolved = str(svo_path.resolve())
svo_key = item.get("svo")
if svo_key:
try:
if Path(str(svo_key)).resolve() == svo_path.resolve():
return True
except OSError:
pass
if str(svo_key) == resolved:
return True
if item.get("svo_name") == stem:
return True
return False
def _load_weight_json(svo_path: Path, settings: Settings) -> Dict[str, Any]:
"""读取 FishMeasure 合并结果。优先 per-SVO 的 weight_prediction.json否则从 weight_predictions_summary.json 取匹配项predict 脚本在权重步失败时仍 exit 0 只写 summary"""
stem = svo_path.stem
root = settings.measure_output_root
candidate = root / stem / "weight_prediction.json"
if candidate.is_file():
with open(candidate, encoding="utf-8") as f:
return json.load(f)
summary_path = root / "weight_predictions_summary.json"
if summary_path.is_file():
with open(summary_path, encoding="utf-8") as f:
summary_list: Any = json.load(f)
if isinstance(summary_list, list):
for item in reversed(summary_list):
if not isinstance(item, dict):
continue
if not _summary_entry_matches_svo(item, svo_path):
continue
err = item.get("error")
if err:
raise RuntimeError(
f"FishMeasure 权重步骤失败({svo_path.name}: {err}"
)
if item.get("per_cloud") or item.get("per_file") or item.get(
"dgcnn_summary"
):
return item
break
combined_path = root / "weight_prediction.json"
if combined_path.is_file():
with open(combined_path, encoding="utf-8") as f:
combined: Any = json.load(f)
if isinstance(combined, dict) and combined.get("combined"):
names = combined.get("svo_names") or []
if stem in names:
return combined
raise FileNotFoundError(
f"未找到测量结果 JSON{candidate}(且 summary 中无本条 SVO 的成功记录)"
)
_TID_RE = re.compile(r"_tid(\d+)")
def _parse_tid_from_ply_name(name: str) -> Optional[int]:
"""与 FishMeasure/fish_video_weight_evaluation._parse_tid_from_ply_name 一致。"""
m = _TID_RE.search(name)
return int(m.group(1)) if m else None
def _safe_media_prefix(stem: str) -> str:
s = re.sub(r"[^\w.\-]+", "_", stem, flags=re.UNICODE).strip("._") or "svo"
return s[:120]
def _result_from_weight_prediction(data: Dict[str, Any]) -> List[Dict[str, Any]]:
"""按 track_id 聚合:体重取 max(predicted_weight_g),体长取达到 max 的那条 PLY 的 length_input (mm)。"""
items = data.get("per_cloud") or data.get("per_file") or []
if not isinstance(items, list):
return []
# tid -> (max_weight_g, length_mm at max weight)
best: Dict[int, Tuple[float, float]] = {}
for it in items:
if not isinstance(it, dict):
continue
ply = it.get("ply")
if not ply:
continue
tid = _parse_tid_from_ply_name(Path(str(ply)).name)
if tid is None:
continue
try:
wg = float(it.get("predicted_weight_g", float("nan")))
except (TypeError, ValueError):
continue
if not math.isfinite(wg):
continue
try:
ln = float(it.get("length_input", float("nan")))
except (TypeError, ValueError):
ln = float("nan")
if tid not in best or wg > best[tid][0]:
best[tid] = (wg, ln)
out: List[Dict[str, Any]] = []
for tid in sorted(best.keys()):
wg, ln = best[tid]
if not math.isfinite(ln):
continue
out.append({"id": tid, "weight": wg, "length": ln})
return out
def _find_preview_videos(output_dir: Path) -> Tuple[Optional[Path], Optional[Path]]:
@@ -173,10 +280,12 @@ def _publish_media(
left: Optional[Path],
right: Optional[Path],
settings: Settings,
file_prefix: str,
) -> 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"
safe_p = _safe_media_prefix(file_prefix)
left_dst = settings.media_root / f"{safe_p}_left.mp4"
right_dst = settings.media_root / f"{safe_p}_right.mp4"
base = settings.public_base_url.rstrip("/")
if left is not None and left == right and left.is_file():
@@ -200,37 +309,39 @@ def _publish_media(
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")
length_mm = summary.get("avg_length_input_topk")
if weight_g is None:
weight_g = data.get("avg_predicted_weight_g")
if length_mm is None:
length_mm = summary.get("avg_length_input") or data.get("avg_length_input")
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,
}
result: List[Dict[str, Any]] = []
if weight_g is not None and length_mm is not None:
try:
w = float(weight_g)
l = float(length_mm)
if math.isfinite(w) and math.isfinite(l):
result = [{"id": 1, "weight": w, "length": l}]
except (TypeError, ValueError):
pass
logger.info(
"[FishMeasure] parsed {}\navg_length_mm={} avg_weight_g={}\nweight_summary:\n{}",
"[FishMeasure] parsed {}\navg_weight_g(top5)={} avg_length_mm(top5)={}\nresult:\n{}\ndgcnn_summary:\n{}",
svo_path.name,
length_mm,
weight_g,
length_mm,
format_json_pretty(result),
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)
prefix = (
f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}_{svo_path.stem}"
)
v_left, v_right = _publish_media(lv, rv, settings, prefix)
logger.info(
"[FishMeasure] media preview_paths={} {} | published_left={} published_right={}",
lv,
@@ -240,7 +351,7 @@ def build_measure_snapshot(svo_path: Path, settings: Settings) -> MeasureSnapsho
)
return MeasureSnapshot(
result=[result_item],
result=result,
video_left=v_left,
video_right=v_right,
updated_at=datetime.now(timezone.utc),