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)
|