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}"}