File size: 13,359 Bytes
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
 
 
c61c435
f8c73f9
c61c435
 
 
 
 
 
 
 
f8c73f9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
 
 
 
c61c435
 
f8c73f9
 
 
 
c61c435
 
 
 
 
f8c73f9
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
 
 
 
 
 
 
 
 
 
 
 
 
 
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
c61c435
 
 
 
 
 
 
 
f8c73f9
 
 
c61c435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8c73f9
 
 
 
 
 
 
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
from __future__ import annotations

import json
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from PIL import Image


BINARY_ACTIONS = {
    "w", "a", "s", "d", "jump", "arrow_up", "arrow_left", "arrow_down",
    "arrow_right", "enter", "shift", "ctrl", "alt", "tab", "escape",
    "q", "e", "r", "f", "z", "x", "c", "v", "key_1", "key_2", "key_3",
    "key_4", "mouse_left", "mouse_middle", "mouse_right",
}
CONTINUOUS_ACTIONS = {"mouse_dx", "mouse_dy", "zoom"}
DERIVED_ACTIONS = {
    "move_x", "move_y", "right_mouse", "camera_active",
    "camera_yaw_delta_degrees", "camera_pitch_delta_degrees",
    "mouse_raw_dx", "mouse_raw_dy",
}
SUPPORTED_ACTIONS = BINARY_ACTIONS | CONTINUOUS_ACTIONS | DERIVED_ACTIONS
REQUIRED_CANONICAL_ACTIONS = {"w", "a", "s", "d", "jump"}
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
RECORDED_ACTIONS = BINARY_ACTIONS | CONTINUOUS_ACTIONS
METADATA_FIELDS = {
    "session_id", "session_started_at", "frame_index", "filename",
    "timestamp_seconds", "camera_encoding",
}


@dataclass(slots=True)
class OasisDatasetReport:
    dataset_folders: list[str] = field(default_factory=list)
    frames: int = 0
    metadata_rows: int = 0
    valid_rows: int = 0
    valid_transitions: int = 0
    sessions: int = 0
    resolution: str = ""
    capture_fps: float | None = None
    recommended_frame_gap: int | None = None
    native_ai_fps: float | None = None
    action_counts: dict[str, int] = field(default_factory=dict)
    idle_rows: int = 0
    errors: list[str] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)

    @property
    def ok(self) -> bool:
        return not self.errors


def _read_dataset_info(directory: Path) -> dict[str, Any]:
    """Read optional recorder metadata without making it a dataset requirement."""
    try:
        value = json.loads((directory / "dataset_info.json").read_text(encoding="utf-8"))
        return value if isinstance(value, dict) else {}
    except (OSError, json.JSONDecodeError):
        return {}


def _recommended_frame_gap(capture_fps: float, _camera_encoding: str) -> int:
    """Choose a playable horizon that targets about 12 genuine AI frames per second.

    This intentionally differs from the external trainer's older movement-focused
    heuristic. A 12–15 FPS recording should train at gap 1, rather than being
    slowed to a 3–5 FPS playable world before GPU speed is even considered.
    """
    target_ai_fps = 12.0
    return max(1, min(12, round(capture_fps / target_ai_fps)))


def oasis_pace(
    value: str | list[str] | tuple[str, ...], *, frame_gap: int,
) -> dict[str, float | int | None]:
    """Return portable pacing information derived from connected recorder metadata.

    A model only makes one genuine frame for every prediction horizon. Display
    interpolation can look smoother, but cannot make controls more responsive.
    """
    rates: list[float] = []
    camera_encodings: list[str] = []
    for directory in dataset_directories(value):
        info = _read_dataset_info(directory)
        try:
            rate = float(info.get("capture_fps"))
        except (TypeError, ValueError):
            continue
        if rate > 0:
            rates.append(rate)
            camera_encodings.append(str(info.get("camera_encoding", "legacy_pixels")))
    if not rates or len({round(rate, 6) for rate in rates}) != 1:
        return {"capture_fps": None, "recommended_frame_gap": None, "native_ai_fps": None}
    capture_fps = rates[0]
    camera_encoding = (
        "relative_degrees_v1"
        if "relative_degrees_v1" in camera_encodings
        else camera_encodings[0]
    )
    gap = max(1, int(frame_gap))
    return {
        "capture_fps": capture_fps,
        "recommended_frame_gap": _recommended_frame_gap(capture_fps, camera_encoding),
        "native_ai_fps": capture_fps / gap,
    }


def dataset_directories(value: str | list[str] | tuple[str, ...]) -> list[Path]:
    entries = value if isinstance(value, (list, tuple)) else str(value or "").split(";")
    directories: list[Path] = []
    for entry in entries:
        text = str(entry).strip().strip('"')
        if not text:
            continue
        path = Path(text).expanduser()
        if path not in directories:
            directories.append(path)
    return directories


def _numeric_frame_index(path: Path) -> int | None:
    match = re.search(r"frame_(\d+)", path.stem, re.I) or re.search(r"(\d+)", path.stem)
    return int(match.group(1)) if match else None


def validate_oasis_dataset(
    value: str | list[str] | tuple[str, ...], *, frame_gap: int = 1,
    verify_images: bool = True,
) -> OasisDatasetReport:
    report = OasisDatasetReport()
    frame_gap = max(1, int(frame_gap))
    pace = oasis_pace(value, frame_gap=frame_gap)
    report.capture_fps = pace["capture_fps"]  # type: ignore[assignment]
    report.recommended_frame_gap = pace["recommended_frame_gap"]  # type: ignore[assignment]
    report.native_ai_fps = pace["native_ai_fps"]  # type: ignore[assignment]
    directories = dataset_directories(value)
    if not directories:
        report.errors.append("Select at least one Oasis action dataset folder.")
        return report
    seen_resolution: tuple[int, int] | None = None
    action_counts = {name: 0 for name in sorted(RECORDED_ACTIONS)}
    transition_total = 0
    session_ids: set[str] = set()

    for directory in directories:
        resolved = directory.resolve()
        report.dataset_folders.append(str(resolved))
        if not directory.is_dir():
            report.errors.append(f"Dataset folder does not exist: {directory}")
            continue
        frames_dir = directory / "frames"
        actions_path = directory / "actions.jsonl"
        if not frames_dir.is_dir():
            report.errors.append(f"{directory} is missing a frames folder.")
            continue
        if not actions_path.is_file():
            report.errors.append(f"{directory} is missing actions.jsonl.")
            continue
        frame_files = sorted(path for path in frames_dir.iterdir() if path.is_file() and path.suffix.casefold() in IMAGE_EXTENSIONS)
        report.frames += len(frame_files)
        if not frame_files:
            report.errors.append(f"{directory} has no image frames.")
        indexed_frames = [index for index in (_numeric_frame_index(path) for path in frame_files) if index is not None]
        frame_paths_by_index: dict[int, list[Path]] = {}
        for frame_file in frame_files:
            index = _numeric_frame_index(frame_file)
            if index is not None:
                frame_paths_by_index.setdefault(index, []).append(frame_file)
        if indexed_frames:
            gaps = [
                (left, right) for left, right in zip(indexed_frames, indexed_frames[1:])
                if right != left + 1
            ]
            if gaps:
                report.warnings.append(f"{directory} has frame ordering gaps such as {gaps[0][0]} to {gaps[0][1]}; invalid transitions will be skipped.")
        rows_by_session: dict[str, list[dict[str, Any]]] = {}
        seen_keys: set[tuple[str, int]] = set()
        for line_number, line in enumerate(actions_path.read_text(encoding="utf-8").splitlines(), 1):
            line = line.strip()
            if not line:
                continue
            report.metadata_rows += 1
            try:
                row = json.loads(line)
            except json.JSONDecodeError:
                report.errors.append(f"{actions_path.name} line {line_number} is not valid JSON.")
                continue
            filename = str(row.get("filename", "")).strip()
            if not filename:
                report.errors.append(f"{actions_path.name} line {line_number} has no frame filename.")
                continue
            frame_path = frames_dir / filename
            if not frame_path.is_file():
                try:
                    frame_index = int(row.get("frame_index"))
                except (TypeError, ValueError):
                    report.warnings.append(
                        f"{actions_path.name} line {line_number} points to missing frame {filename}; skipping row."
                    )
                    continue
                candidates = frame_paths_by_index.get(frame_index, [])
                if len(candidates) == 1:
                    frame_path = candidates[0]
                    report.warnings.append(
                        f"{actions_path.name} line {line_number} uses {frame_path.name} for missing legacy filename {filename}."
                    )
                else:
                    report.warnings.append(
                        f"{actions_path.name} line {line_number} points to missing frame {filename}; skipping row."
                    )
                    continue
            if verify_images:
                try:
                    with Image.open(frame_path) as image:
                        image.verify()
                    with Image.open(frame_path) as image:
                        size = image.size
                except Exception as exc:
                    report.errors.append(f"Broken image file {frame_path.name}: {exc}")
                    continue
                if seen_resolution is None:
                    seen_resolution = size
                    report.resolution = f"{size[0]}x{size[1]}"
                elif size != seen_resolution:
                    report.errors.append(f"Inconsistent frame resolution: {frame_path.name} is {size[0]}x{size[1]}, expected {seen_resolution[0]}x{seen_resolution[1]}.")
            unexpected = sorted(set(row) - SUPPORTED_ACTIONS - METADATA_FIELDS)
            if unexpected:
                report.errors.append(f"{actions_path.name} line {line_number} contains unsupported action field(s): {', '.join(unexpected[:6])}.")
            missing = sorted(name for name in REQUIRED_CANONICAL_ACTIONS if name not in row)
            if missing:
                report.errors.append(f"{actions_path.name} line {line_number} is missing action label(s): {', '.join(missing)}.")
                continue
            try:
                frame_index = int(row.get("frame_index"))
            except (TypeError, ValueError):
                report.errors.append(f"{actions_path.name} line {line_number} has an invalid frame_index.")
                continue
            session_id = str(row.get("session_id") or f"legacy-{directory.name}").strip()
            key = (session_id, frame_index)
            if key in seen_keys:
                report.errors.append(f"{actions_path.name} repeats frame_index {frame_index} in session {session_id}.")
                continue
            seen_keys.add(key)
            action_active = False
            for name in action_counts:
                try:
                    value = float(row.get(name, 0))
                except (TypeError, ValueError):
                    report.errors.append(f"{actions_path.name} line {line_number} has invalid {name} action value.")
                    value = 0.0
                if abs(value) > (0.5 if name in BINARY_ACTIONS else 0.02):
                    action_counts[name] += 1
                    action_active = True
            if not action_active:
                report.idle_rows += 1
            row["_session_id"] = session_id
            rows_by_session.setdefault(session_id, []).append(row)
            session_ids.add(f"{resolved}:{session_id}")
            report.valid_rows += 1
        for session_id, rows in rows_by_session.items():
            if not rows:
                report.errors.append(f"{directory} has an empty sequence {session_id}.")
                continue
            rows.sort(key=lambda item: int(item["frame_index"]))
            for left, right in zip(rows, rows[1:]):
                if int(right["frame_index"]) != int(left["frame_index"]) + 1:
                    report.warnings.append(f"{directory} session {session_id} has an ordering gap at frame {left['frame_index']}; invalid transitions will be skipped.")
            transition_total += sum(
                1 for left, right in zip(rows, rows[frame_gap:])
                if int(right["frame_index"]) == int(left["frame_index"]) + frame_gap
            )

    report.sessions = len(session_ids)
    report.action_counts = action_counts
    report.valid_transitions = transition_total
    if report.metadata_rows != report.frames:
        report.warnings.append(f"Frame and label counts differ: {report.frames} frame files, {report.metadata_rows} action rows.")
    if report.valid_rows < 2:
        report.errors.append("The dataset needs at least two valid labelled frames.")
    if report.valid_transitions < 1:
        report.errors.append(f"No valid frame transitions were found for prediction horizon {frame_gap}.")
    if report.valid_rows and not any(action_counts.values()):
        report.errors.append("No non-idle action labels were found. Record idle plus at least one active control.")
    return report


def inspect_oasis_dataset(
    value: str | list[str] | tuple[str, ...], *, frame_gap: int = 1,
) -> OasisDatasetReport:
    """Quickly inspect training-relevant labels and pacing without decoding images."""
    return validate_oasis_dataset(value, frame_gap=frame_gap, verify_images=False)