File size: 5,753 Bytes
261137b
 
dee00d5
261137b
dee00d5
 
 
261137b
dee00d5
 
 
 
261137b
dee00d5
 
 
 
261137b
dee00d5
261137b
dee00d5
 
 
 
 
 
261137b
 
cffabad
dee00d5
261137b
 
 
 
 
 
 
 
dee00d5
 
 
 
261137b
 
 
dee00d5
 
 
 
 
 
261137b
dee00d5
 
 
261137b
 
 
cffabad
 
dee00d5
 
 
 
 
261137b
dee00d5
 
261137b
dee00d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261137b
dee00d5
590a794
dee00d5
261137b
 
dee00d5
 
 
 
261137b
dee00d5
 
261137b
dee00d5
261137b
dee00d5
 
261137b
dee00d5
 
261137b
 
dee00d5
261137b
 
 
 
 
 
 
 
dee00d5
261137b
dee00d5
 
 
 
261137b
dee00d5
 
 
261137b
 
dee00d5
 
261137b
dee00d5
261137b
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""
Check temporal alignment for the OAK + Arducam rig.

Each check cross-correlates two motion signals; the lag of the correlation peak
is the timing offset. Two checks run by default, both off a single (shared)
Arducam optical-flow pass:

  1. Arducam ↔ trajectory     (image↔pose, the alignment the policy trains on)
     Arducam optical flow vs the SLAM trajectory's angular velocity. Goes through
     the actual SLAM output, so it measures the real image↔pose offset (not just
     image↔gyro). POST-SLAM: needs camera_trajectory.csv.

  2. Arducam ↔ gripper angle  (angle-stream sync)
     Arducam optical flow vs gripper joint-angle speed. Only meaningful on a
     deliberate open/close gesture filmed with the camera roughly still; a low
     correlation means no gesture / egomotion-dominated (inconclusive).

Opt-in (--vio-health), a second/slower optical-flow pass:

  3. OAK-left ↔ OAK-gyro      (SLAM VIO health)
     Same device, shared OAK clock. Confirms the visual-inertial SLAM inputs are
     time-aligned. Largely subsumed by check 1 (a bad trajectory shows up there),
     so it is a debugging aid rather than a default.

The correlation core and data loaders live in grabette_postprocess.checks.sync.

Usage:
    uv run python scripts/checks/check_sync.py -i /path/to/episode
    uv run python scripts/checks/check_sync.py -i /path/to/episode --vio-health --plot sync.png
"""

from pathlib import Path

import click
import numpy as np

from grabette_postprocess.checks.sync import (
    _arducam_flow,
    check_gripper,
    check_image_trajectory,
    check_oak_imu,
)


def _report(res: dict) -> None:
    """Print a verdict block for one check."""
    approx = " (approx: no frame timestamps)" if res["approx"] else ""
    print(f"\n  {res['label']}{approx}")
    print(f"    best lag:    {res['lag'] * 1000:+.1f} ms")
    print(f"    correlation: {res['corr']:.3f}")
    arrow = {"GOOD": "GOOD (< 20ms)",
             "MARGINAL": "MARGINAL (20-50ms)",
             "BAD": "BAD (> 50ms)"}[res["verdict"]]
    print(f"    → {arrow}" + (f" — {res['note']}" if res["note"] else ""))


@click.command()
@click.option("-i", "--input_dir", "episode_dir", required=True,
              type=click.Path(exists=True), help="Episode directory to check")
@click.option("--max_frames", type=int, default=300,
              help="Max consecutive video frames per camera (default: 300 ≈ first ~6s). "
                   "Raise it for the gripper check on long episodes so later gestures aren't cut off.")
@click.option("--vio-health", is_flag=True, default=False,
              help="Also check OAK-left↔gyro (SLAM-input health) — a second, slower optical-flow pass")
@click.option("--plot", "-p", type=click.Path(), default=None, help="Save a correlation plot (PNG)")
def main(episode_dir, max_frames, vio_health, plot):
    """Check temporal alignment via optical-flow cross-correlation."""
    episode_dir = Path(episode_dir)

    # The two checks that matter for the dataset (image↔pose, angle sync) share a
    # single Arducam optical-flow pass — the expensive Farneback part. The
    # OAK-left↔gyro SLAM-input check needs its own second flow pass and is largely
    # subsumed by image↔trajectory, so it is opt-in (--vio-health).
    print("Computing Arducam optical flow (raw_video.mp4)...")
    arducam_flow = _arducam_flow(episode_dir, max_frames)
    if arducam_flow[0] is None:
        print("  (no usable raw_video.mp4 — image↔trajectory and gripper checks skipped)")
    else:
        print(f"  {len(arducam_flow[1])} frames, {arducam_flow[0][-1] - arducam_flow[0][0]:.1f}s")

    checks = [
        ("Arducam ↔ trajectory", lambda: check_image_trajectory(episode_dir, max_frames, flow=arducam_flow)),
        ("Arducam ↔ gripper", lambda: check_gripper(episode_dir, max_frames, flow=arducam_flow)),
    ]
    if vio_health:
        print("Computing depth-camera left optical flow (dcam_left.mp4)...")
        checks.append(("OAK-left ↔ OAK-gyro", lambda: check_oak_imu(episode_dir, max_frames)))

    results = []
    for name, run in checks:
        res = run()
        if res is None:
            print(f"\n  {name}: skip (missing inputs)")
            continue
        _report(res)
        results.append(res)

    print(f"\n{'=' * 60}")
    for r in results:
        print(f"  {r['verdict']:9s} {r['label']}  ({r['lag'] * 1000:+.1f}ms, corr={r['corr']:.2f})")
    print(f"{'=' * 60}")

    if plot and results:
        _save_plot(plot, results)


def _save_plot(path, results):
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except ImportError:
        print("\nmatplotlib not installed, skipping plot")
        return

    n = len(results)
    fig, axes = plt.subplots(n, 2, figsize=(14, 4 * n), squeeze=False)
    for row, r in enumerate(results):
        def norm(x):
            m = np.max(x)
            return x / m if m > 0 else x
        ax = axes[row][0]
        ax.plot(r["cam_ts"], norm(r["cam_sig"]), label="Arducam/OAK flow", alpha=0.8)
        ax.plot(r["ref_ts"], norm(r["ref_sig"]), label=r["ref_label"], alpha=0.8)
        ax.set_title(f"{r['label']} — signals")
        ax.set_xlabel("time (s)"); ax.legend()
        ax = axes[row][1]
        ax.plot(r["lag_times"] * 1000, r["corr_curve"])
        ax.axvline(r["lag"] * 1000, color="r", ls="--", label=f"{r['lag'] * 1000:+.1f}ms")
        ax.axvline(0, color="gray", ls=":", alpha=0.5)
        ax.set_title(f"{r['label']} — cross-correlation")
        ax.set_xlabel("lag (ms)"); ax.legend()
    plt.tight_layout()
    plt.savefig(path, dpi=150)
    print(f"\nPlot saved to: {path}")


if __name__ == "__main__":
    main()