Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211 Download processor/app/video_quality.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/video_quality.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/app/video_quality.py
-
curl -L -o video_quality.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/app/video_quality.py
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 | |