Spaces:
Running
Running
File size: 14,777 Bytes
d646dd7 bb59211 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 | """Data cleaning / validation — runs on uploaded episodes before review.
Checks:
1. Frame alignment — parquet frame count vs video frame count
2. Sensor completeness — observation.state not all-zero
3. Video decode — MP4 files readable by OpenCV
"""
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import Episode
def utcnow():
return datetime.now(timezone.utc)
async def validate_episode(ep: Episode, session_dir: Optional[Path], db: AsyncSession) -> dict:
"""Run all validation checks on a single episode.
Returns the cleaning_report dict: {passed, checks: [{name, passed, detail}], summary}
"""
checks = []
# ── 1. Frame alignment check ──
checks.append(await _check_frame_alignment(ep, session_dir))
# ── 2. Sensor completeness ──
checks.append(await _check_sensor_completeness(ep, session_dir))
# ── 3. Video decode check ──
checks.append(await _check_video_decode(ep))
# ── 4. Device connectivity check ──
checks.append(await _check_device_connectivity(ep, session_dir))
passed = all(c["passed"] for c in checks)
failed_names = [c["name"] for c in checks if not c["passed"]]
report = {
"passed": passed,
"checks": checks,
"summary": "All checks passed" if passed else f"Failed: {', '.join(failed_names)}",
"validated_at": utcnow().isoformat(),
}
# Update episode
ep.cleaning_report = report
ep.processing_started_at = utcnow()
ep.review_ready_at = utcnow()
if passed:
# Map old legacy statuses appropriately
if ep.status not in ("reviewed", "approved", "rejected"):
ep.status = "to_review"
else:
if ep.status not in ("reviewed", "approved"):
ep.status = "failed"
await db.flush()
return report
# ── Individual checks ──────────────────────────────────────────
async def _check_frame_alignment(ep: Episode, session_dir: Optional[Path]) -> dict:
"""Compare parquet frame count with video frame count."""
if not session_dir or not session_dir.exists():
return {"name": "frame_alignment", "passed": True,
"detail": "No session dir, skipped"}
try:
import pandas as pd
parquet_frames = 0
seen_indices = set()
for data_dir in session_dir.rglob("data"):
if not data_dir.is_dir():
continue
for pq_file in data_dir.rglob("*.parquet"):
try:
df = pd.read_parquet(pq_file)
if "frame_index" in df.columns:
for fi in df["frame_index"]:
seen_indices.add(int(fi))
except Exception:
continue
parquet_frames = len(seen_indices)
video_frames = ep.frame_count or 1
if parquet_frames == 0:
return {"name": "frame_alignment", "passed": True,
"detail": "No parquet data to compare"}
ratio = abs(parquet_frames - video_frames) / max(video_frames, 1)
passed = ratio < 0.10 # < 10% deviation
return {
"name": "frame_alignment",
"passed": passed,
"detail": f"parquet={parquet_frames}, video={video_frames}, dev={ratio:.1%}",
}
except Exception as e:
return {"name": "frame_alignment", "passed": True,
"detail": f"Check error (skipped): {e}"}
async def _check_sensor_completeness(ep: Episode, session_dir: Optional[Path]) -> dict:
"""Check that observation.state data is present (not all-zero for all frames)."""
if not session_dir or not session_dir.exists():
return {"name": "sensor_completeness", "passed": True,
"detail": "No session dir, skipped"}
try:
import pandas as pd
import numpy as np
for data_dir in session_dir.rglob("data"):
if not data_dir.is_dir():
continue
for pq_file in data_dir.rglob("*.parquet"):
try:
df = pd.read_parquet(pq_file)
# Look for observation columns
obs_cols = [c for c in df.columns if c.startswith("observation.")]
if not obs_cols:
continue
# Sample first 10 frames, check not all zero
sample = df[obs_cols[0]].head(min(10, len(df)))
all_zero = True
for val in sample:
if isinstance(val, (list, np.ndarray)):
arr = np.array(val, dtype=np.float32)
if arr.max() > 0:
all_zero = False
break
if all_zero:
return {
"name": "sensor_completeness",
"passed": False,
"detail": "All sampled observation values are zero",
}
return {
"name": "sensor_completeness",
"passed": True,
"detail": f"Checked {len(sample)} frames, data present",
}
except Exception:
continue
# No observation columns found — video-only episode, OK
return {"name": "sensor_completeness", "passed": True,
"detail": "No observation columns (video-only episode)"}
except Exception as e:
return {"name": "sensor_completeness", "passed": True,
"detail": f"Check error (skipped): {e}"}
async def _check_video_decode(ep: Episode) -> dict:
"""Verify the associated MP4 video is readable by OpenCV."""
try:
import cv2
except ImportError:
return {"name": "video_decode", "passed": True,
"detail": "OpenCV not available, skipped"}
video_path = None
if ep.meta and ep.meta.get("video_path"):
p = settings.storage_root / ep.meta["video_path"]
if p.exists():
video_path = p
if not video_path:
# Try scanning sessions dir
sessions_root = settings.storage_root / "sessions"
if sessions_root.exists():
for mp4 in sessions_root.rglob("*.mp4"):
video_path = mp4
break
if not video_path:
return {"name": "video_decode", "passed": True,
"detail": "No video file found to check"}
try:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
return {"name": "video_decode", "passed": False,
"detail": f"Cannot open: {video_path.name}"}
ret, _ = cap.read()
cap.release()
if not ret:
return {"name": "video_decode", "passed": False,
"detail": f"Cannot read first frame: {video_path.name}"}
return {"name": "video_decode", "passed": True,
"detail": f"Video readable: {video_path.name}"}
except Exception as e:
return {"name": "video_decode", "passed": False,
"detail": f"Decode error: {e}"}
async def _check_device_connectivity(ep: Episode, session_dir: Optional[Path]) -> dict:
"""Scan parquet for status.* columns — flag disconnected devices as failed."""
if not session_dir or not session_dir.exists():
return {"name": "device_connectivity", "passed": True,
"detail": "No session dir, skipped"}
try:
import pandas as pd
disconnected_devices: set[str] = set()
for data_dir in session_dir.rglob("data"):
if not data_dir.is_dir():
continue
for pq_file in data_dir.rglob("*.parquet"):
try:
df = pd.read_parquet(pq_file)
status_cols = [c for c in df.columns if c.startswith("status.")]
for col in status_cols:
if "disconnected" in df[col].values:
device_name = col.split(".", 1)[1]
disconnected_devices.add(device_name)
except Exception:
continue
if disconnected_devices:
return {
"name": "device_connectivity",
"passed": False,
"detail": f"Disconnected: {', '.join(sorted(disconnected_devices))}",
}
return {
"name": "device_connectivity",
"passed": True,
"detail": "All devices connected",
}
except Exception as e:
return {"name": "device_connectivity", "passed": True,
"detail": f"Check error (skipped): {e}"}
# ── 文件级校验(无数据库版本)──────────────────────────
def validate_batch(batch_dir: Path) -> dict:
"""校验一个批次目录(无数据库):帧对齐 / 传感器 / 视频解码。"""
checks = [
_check_frame_alignment_fs(batch_dir),
_check_sensor_completeness_fs(batch_dir),
_check_video_decode_fs(batch_dir),
]
passed = all(c["passed"] for c in checks)
failed_names = [c["name"] for c in checks if not c["passed"]]
return {
"passed": passed,
"checks": checks,
"summary": "All checks passed" if passed else f"Failed: {', '.join(failed_names)}",
"validated_at": utcnow().isoformat(),
}
def _check_frame_alignment_fs(batch_dir: Path) -> dict:
"""parquet 帧数 vs 视频帧数(±10%)。"""
try:
import pandas as pd
seen = set()
for data_dir in batch_dir.rglob("data"):
if not data_dir.is_dir():
continue
for pq_file in data_dir.rglob("*.parquet"):
try:
df = pd.read_parquet(pq_file)
if "frame_index" in df.columns:
seen.update(int(fi) for fi in df["frame_index"])
except Exception:
continue
parquet_frames = len(seen)
if parquet_frames == 0:
return {"name": "frame_alignment", "passed": True, "detail": "No parquet data"}
# 2026-09-23 修:此处此前直接用 cv2 却没 import,异常被下方 except 吞掉,
# 整个帧对齐检查在文件级路径下**永远静默通过**(detail 里写的是
# "Check error (skipped)" 但 passed=True,不会引起任何人注意)。
try:
import cv2
except ImportError:
return {"name": "frame_alignment", "passed": True,
"detail": "OpenCV not available — video frame count skipped"}
mp4s = sorted(batch_dir.rglob("*.mp4"))
if not mp4s:
# 与 _check_video_decode_fs 同款处理:没有视频就没有可比对的对象,
# 判"未适用"而不是"不匹配"——只有传感器的批次(如纯手套)是合法的。
# 修复 cv2 漏 import 后,这里若不给守卫会退化成 dev=10000% 的误报。
return {"name": "frame_alignment", "passed": True,
"detail": "No video to compare against"}
video_frames = 0
for mp4 in mp4s:
cap = cv2.VideoCapture(str(mp4))
try:
video_frames = max(video_frames, int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) or 0)
finally:
cap.release()
ratio = abs(parquet_frames - video_frames) / max(video_frames, 1)
return {"name": "frame_alignment", "passed": ratio < 0.10,
"detail": f"parquet={parquet_frames}, video={video_frames}, dev={ratio:.1%}"}
except Exception as e:
# 保持 passed=True(不改变既有行为),但 detail 必须说清是"降级通过",
# 免得再出现"看起来全过了其实根本没检查"的情况。
return {"name": "frame_alignment", "passed": True,
"detail": f"Degraded pass — check errored: {e}"}
def _check_sensor_completeness_fs(batch_dir: Path) -> dict:
"""采样前 10 帧,检查 observation 数据非全零。"""
try:
import pandas as pd
import numpy as np
for data_dir in batch_dir.rglob("data"):
if not data_dir.is_dir():
continue
for pq_file in data_dir.rglob("*.parquet"):
try:
df = pd.read_parquet(pq_file)
obs_cols = [c for c in df.columns if c.startswith("observation.")]
if not obs_cols:
continue
sample = df[obs_cols[0]].head(min(10, len(df)))
for val in sample:
if isinstance(val, (list, np.ndarray)):
if np.array(val, dtype=np.float32).max() > 0:
return {"name": "sensor_completeness", "passed": True,
"detail": f"Checked {len(sample)} frames, data present"}
return {"name": "sensor_completeness", "passed": False,
"detail": "All sampled observation values are zero"}
except Exception:
continue
return {"name": "sensor_completeness", "passed": True,
"detail": "No observation columns (video-only episode)"}
except Exception as e:
return {"name": "sensor_completeness", "passed": True,
"detail": f"Check error (skipped): {e}"}
def _check_video_decode_fs(batch_dir: Path) -> dict:
"""验证批次内第一个 MP4 可被 OpenCV 读取。"""
try:
import cv2
except ImportError:
return {"name": "video_decode", "passed": True, "detail": "OpenCV not available"}
mp4s = sorted(batch_dir.rglob("*.mp4"))
if not mp4s:
return {"name": "video_decode", "passed": True, "detail": "No video file"}
try:
cap = cv2.VideoCapture(str(mp4s[0]))
if not cap.isOpened():
return {"name": "video_decode", "passed": False,
"detail": f"Cannot open: {mp4s[0].name}"}
ret, _ = cap.read()
cap.release()
return {"name": "video_decode", "passed": ret,
"detail": f"Video readable: {mp4s[0].name}"}
except Exception as e:
return {"name": "video_decode", "passed": False, "detail": f"Decode error: {e}"}
|