DataFlowing / processor /app /video_quality.py
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211
Raw History Blame Contribute Delete
15.4 kB
"""Post-processing video quality checks.
The checks in this module are deliberately media-only. They do not try to
decide which hand is left/right or whether an action label is correct. A
workflow can use them as a gate before the existing ``reviewed``/Approved
state:
* the file opens and sampled frames can be decoded;
* a stream is not materially shorter than the episode timeline;
* sampled frames are not almost entirely black;
* sampled frames do not remain identical for a configurable duration.
An encoded MP4 does not always retain the capture device's original frame
index. Therefore the frame-count check detects a materially short stream,
while exact capture-time drops require source ``frame_index``/PTS metadata and
are reported separately by future collectors. The result is fail-closed for
automatic approval, but the old UI still only exposes Reviewing/Approved.
"""
from __future__ import annotations
import asyncio
import os
from pathlib import Path
# 状态写入走 localstore.mutate_episode_state(锁内 RMW),在用到的地方局部导入 ——
# 见 _apply_video_quality_result 的注释。
DEFAULT_SAMPLE_INTERVAL_SEC = 0.5
DEFAULT_MAX_SAMPLES = 240
DEFAULT_FREEZE_MIN_SEC = 2.0
DEFAULT_FREEZE_DIFF = 0.75
DEFAULT_BLACK_MEAN = 5.0
DEFAULT_BLACK_DARK_RATIO = 0.995
DEFAULT_BLACK_MIN_SEC = 0.5
DEFAULT_FRAME_TOLERANCE_RATIO = 0.005
DEFAULT_FRAME_TOLERANCE_MIN = 2
_VIDEO_QC_SEMAPHORE = asyncio.Semaphore(1)
def _float_env(name: str, default: float, minimum: float = 0.0) -> float:
try:
return max(minimum, float(os.getenv(name, str(default))))
except (TypeError, ValueError):
return default
def _int_env(name: str, default: int, minimum: int = 1) -> int:
try:
return max(minimum, int(os.getenv(name, str(default))))
except (TypeError, ValueError):
return default
def _raw_videos(batch_dir: Path) -> list[Path]:
"""Return input videos only; never inspect processed run outputs."""
videos_root = batch_dir / "videos"
if videos_root.is_dir():
paths = [p for p in videos_root.rglob("*")
if p.is_file() and p.suffix.lower() == ".mp4"]
else:
paths = [p for p in batch_dir.rglob("*")
if p.is_file() and p.suffix.lower() == ".mp4"
and "processed" not in p.parts
and "original" not in p.parts
and "tmp" not in p.parts]
return sorted(paths, key=lambda p: str(p).lower())
def _sample_indices(frame_count: int, fps: float) -> tuple[list[int], float]:
"""Build approximately half-second sample positions with a hard cap."""
count = max(0, int(frame_count))
if count <= 0:
return [], 0.0
interval = _float_env(
"EGODATA_VIDEO_QC_SAMPLE_INTERVAL_SEC",
DEFAULT_SAMPLE_INTERVAL_SEC,
minimum=0.05,
)
frame_step = max(1, int(round(max(1.0, fps) * interval)))
indices = list(range(0, count, frame_step))
if indices[-1] != count - 1:
indices.append(count - 1)
max_samples = _int_env(
"EGODATA_VIDEO_QC_MAX_SAMPLES", DEFAULT_MAX_SAMPLES, minimum=3)
if len(indices) > max_samples:
# Keep samples distributed across the whole stream. This may miss a
# very short freeze in a very long video, but it prevents a quality
# check from turning into a full decode pass on remote storage.
positions = [round(i * (len(indices) - 1) / (max_samples - 1))
for i in range(max_samples)]
indices = [indices[position] for position in positions]
return sorted(set(indices)), frame_step / max(1.0, fps)
def _add_freeze_range(ranges: list[list[float]], start: int, end: int,
fps: float) -> None:
if end < start:
return
item = [round(start / max(1.0, fps), 3),
round(end / max(1.0, fps), 3)]
if ranges and item[0] <= ranges[-1][1] + 0.01:
ranges[-1][1] = max(ranges[-1][1], item[1])
else:
ranges.append(item)
def _check_stream(path: Path, expected_frames: int = 0,
expected_fps: float = 0.0) -> dict:
"""Check one video without loading the complete stream into memory."""
try:
import cv2
import numpy as np
except Exception as exc:
return {
"path": str(path),
"passed": False,
"reason": "opencv_unavailable",
"error": str(exc),
}
report: dict = {
"path": str(path),
"passed": False,
"frame_count": 0,
"expected_frame_count": max(0, int(expected_frames or 0)),
"fps": 0.0,
"width": 0,
"height": 0,
"sampled_frames": 0,
"decode_error_count": 0,
"decode_errors": [],
"black_frame_count": 0,
"black_frames": [],
"black_ranges": [],
"freeze_ranges": [],
"dropped_frame_estimate": 0,
"count_tolerance": 0,
}
cap = cv2.VideoCapture(str(path))
if not cap.isOpened():
report["reason"] = "video_open_failed"
cap.release()
return report
try:
frame_count = int(round(float(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)))
fps = float(cap.get(cv2.CAP_PROP_FPS) or 0.0)
width = int(round(float(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)))
height = int(round(float(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)))
report.update(frame_count=max(0, frame_count), fps=round(max(0.0, fps), 4),
width=max(0, width), height=max(0, height))
if frame_count <= 0 or fps <= 0 or width <= 0 or height <= 0:
report["reason"] = "video_metadata_invalid"
return report
indices, sample_interval = _sample_indices(frame_count, fps)
report["sample_interval_sec"] = round(sample_interval, 3)
black_mean = _float_env("EGODATA_VIDEO_QC_BLACK_MEAN",
DEFAULT_BLACK_MEAN, minimum=0.0)
black_dark_ratio = min(
1.0,
_float_env("EGODATA_VIDEO_QC_BLACK_DARK_RATIO",
DEFAULT_BLACK_DARK_RATIO, minimum=0.0),
)
black_min_sec = _float_env("EGODATA_VIDEO_QC_BLACK_MIN_SEC",
DEFAULT_BLACK_MIN_SEC, minimum=0.0)
freeze_min_sec = _float_env("EGODATA_VIDEO_QC_FREEZE_MIN_SEC",
DEFAULT_FREEZE_MIN_SEC, minimum=0.5)
freeze_diff = _float_env("EGODATA_VIDEO_QC_FREEZE_DIFF",
DEFAULT_FREEZE_DIFF, minimum=0.0)
previous_small = None
previous_index = None
freeze_start = None
freeze_last = None
black_start = None
black_last = None
for index in indices:
# Seeking to sparse positions avoids a full decode pass. Each
# seek is still followed by read(), so corrupt samples are caught.
cap.set(cv2.CAP_PROP_POS_FRAMES, index)
ok, frame = cap.read()
if not ok or frame is None:
report["decode_error_count"] += 1
if len(report["decode_errors"]) < 20:
report["decode_errors"].append(index)
previous_small = None
previous_index = None
freeze_start = None
freeze_last = None
black_start = None
black_last = None
continue
report["sampled_frames"] += 1
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
dark_ratio = float(np.mean(gray <= black_mean))
gray_mean = float(np.mean(gray))
if gray_mean <= black_mean and dark_ratio >= black_dark_ratio:
report["black_frame_count"] += 1
if black_start is None:
black_start = index
black_last = index
if len(report["black_frames"]) < 20:
report["black_frames"].append({
"frame": index,
"time_sec": round(index / max(1.0, fps), 3),
"mean": round(gray_mean, 3),
"dark_ratio": round(dark_ratio, 6),
})
elif black_start is not None:
if ((black_last - black_start) / max(1.0, fps)
>= black_min_sec):
_add_freeze_range(report["black_ranges"],
black_start, black_last, fps)
black_start = None
black_last = None
small = cv2.resize(gray, (32, 18), interpolation=cv2.INTER_AREA)
small = small.astype(np.float32)
if previous_small is not None and previous_index is not None:
difference = float(np.mean(np.abs(small - previous_small)))
if difference <= freeze_diff:
if freeze_start is None:
freeze_start = previous_index
freeze_last = index
duration = (index - freeze_start) / max(1.0, fps)
if duration >= freeze_min_sec:
_add_freeze_range(report["freeze_ranges"],
freeze_start, freeze_last, fps)
else:
freeze_start = None
freeze_last = None
previous_small = small
previous_index = index
if black_start is not None and ((black_last - black_start)
/ max(1.0, fps) >= black_min_sec):
_add_freeze_range(report["black_ranges"],
black_start, black_last, fps)
expected = max(0, int(expected_frames or 0))
if expected > 0:
tolerance = max(
DEFAULT_FRAME_TOLERANCE_MIN,
int(round(expected * _float_env(
"EGODATA_VIDEO_QC_FRAME_TOLERANCE_RATIO",
DEFAULT_FRAME_TOLERANCE_RATIO,
minimum=0.0,
))),
)
report["count_tolerance"] = tolerance
deficit = max(0, expected - frame_count)
report["dropped_frame_estimate"] = deficit
report["frame_count_mismatch"] = bool(deficit > tolerance)
else:
report["frame_count_mismatch"] = False
reasons: list[str] = []
if report["decode_error_count"]:
reasons.append("decode_error")
if report["black_ranges"]:
reasons.append("black_screen")
if report["freeze_ranges"]:
reasons.append("freeze_suspected")
if report.get("frame_count_mismatch"):
reasons.append("frame_count_short")
report["passed"] = not reasons
report["reason"] = "passed" if report["passed"] else ",".join(reasons)
report["policy"] = {
"black_mean": black_mean,
"black_dark_ratio": black_dark_ratio,
"black_min_sec": black_min_sec,
"freeze_min_sec": freeze_min_sec,
"freeze_diff": freeze_diff,
}
return report
except Exception as exc:
report["reason"] = "video_check_failed"
report["error"] = str(exc)[:240]
return report
finally:
cap.release()
def check_video_quality(batch_dir: str | Path, expected_frames: int = 0,
expected_fps: float = 0.0) -> dict:
"""Run the bounded post-processing video quality check for a batch."""
root = Path(batch_dir)
try:
videos = _raw_videos(root)
except Exception as exc:
return {
"passed": False,
"reason": "video_scan_failed",
"error": str(exc)[:240],
"streams": [],
"expected_frame_count": max(0, int(expected_frames or 0)),
}
if not videos:
return {
"passed": False,
"reason": "no_input_video",
"streams": [],
"expected_frame_count": max(0, int(expected_frames or 0)),
}
streams = [_check_stream(path, expected_frames, expected_fps)
for path in videos]
passed = all(bool(stream.get("passed")) for stream in streams)
return {
"passed": passed,
"reason": "passed" if passed else "video_quality_failed",
"streams": streams,
"stream_count": len(streams),
"expected_frame_count": max(0, int(expected_frames or 0)),
"expected_fps": round(max(0.0, float(expected_fps or 0.0)), 4),
}
async def check_video_quality_async(batch_dir: str | Path,
expected_frames: int = 0,
expected_fps: float = 0.0) -> dict:
"""Serialize bounded media scans so multiple completions do not flood IO."""
async with _VIDEO_QC_SEMAPHORE:
return await asyncio.to_thread(
check_video_quality, batch_dir, expected_frames, expected_fps)
def _apply_video_quality_result(episode_id: str, report: dict) -> None:
"""Persist the result while preserving the two-state review UI."""
from app.localstore import mutate_episode_state
from app.processing.cleaning.store import is_approval_blocked
def _apply(state: dict) -> None:
state["video_quality_status"] = "passed" if report.get("passed") else "failed"
state["video_quality_report"] = report
current = str(state.get("status") or "")
if report.get("passed"):
# 视频干净 ≠ 数据干净。数据质检判 FAIL/ERROR 的批次留了粘性标记,
# 这里必须让路 —— 否则质检刚推回 to_review,又被本函数置成 reviewed。
if (current in {"to_review", "completed", "reviewed"}
and not is_approval_blocked(state)):
state["status"] = "reviewed"
if not state.get("approved_at"):
state["approved_at"] = _utcnow()
elif current in {"processing", "completed", "reviewed", "approved", "failed"}:
state["status"] = "to_review"
state["approved_at"] = None
# 锁内 RMW:本函数会改 ``status``,而数据质检任务在同一个 run 完成后并发
# 写同一份 state。分开 read/write 的话,质检那边写回的旧快照会把这里的
# to_review 冲掉 —— 失败批次看上去像已通过。
mutate_episode_state(episode_id, _apply)
def _utcnow() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
async def run_video_quality_review(episode_id: str, batch_dir: str | Path,
expected_frames: int = 0,
expected_fps: float = 0.0) -> dict:
"""Run media checks off the event loop and persist the review decision."""
try:
report = await check_video_quality_async(
batch_dir, expected_frames, expected_fps)
except Exception as exc:
# A background quality task must never become an unhandled asyncio
# exception and accidentally leave an old Approved state in place.
report = {
"passed": False,
"reason": "video_check_failed",
"error": str(exc)[:240],
"streams": [],
}
_apply_video_quality_result(episode_id, report)
return report