Spaces:
Running
Running
File size: 15,448 Bytes
d646dd7 bb59211 d646dd7 bb59211 d646dd7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 | """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
|