File size: 16,498 Bytes
9f818c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1

"""Preprocess dataset for inference."""

from pathlib import Path
from typing import Annotated, Any, Literal, cast, get_args

import hydra
import pydantic
import tyro
from loguru import logger
from omegaconf import OmegaConf
from torch.utils.data import Dataset, IterableDataset
from typing_extensions import assert_never

from cosmos_framework.inference.common.args import (
    ConfigFileType,
    ResolvedFilePath,
    ResolvedPath,
    SetupArgs,
)
from cosmos_framework.inference.common.config import deserialize_config_dict
from cosmos_framework.inference.dataset_samples import (
    DatasetSamples,
    IterableDatasetSamples,
    MapDatasetSamples,
)
from cosmos_framework.scripts.dataset_utils import (
    DEFAULT_GCS_CREDENTIALS,
    download_from_gcs_uri,
    override_dataset_root,
)

Split = Literal["train", "val", "full"]
ModelMode = Literal["forward_dynamics", "inverse_dynamics", "policy"]
ALL_MODEL_MODES: list[str] = list(get_args(ModelMode))


def _select_dataloader(
    dataloaders_dict: dict[str, Any],
    dataset_name: str,
) -> tuple[str, dict[str, Any], dict[str, Any], Any]:
    """Pick the right dataloader entry from a multi-dataloader config.

    Selection strategy:
    1. If *dataset_name* exactly matches a key in *dataloaders_dict*, use it.
    2. Otherwise, search each dataloader's ``list_of_datasets`` for an entry
       whose ``name`` matches *dataset_name* and use the first hit.
    3. Fall back to the first dataloader in iteration order.

    Returns ``(dataset_key, dataloader_config, dataset_config, inner_ds)``
    where *dataset_config* is a mutable plain dict (via
    ``OmegaConf.to_container``) and *inner_ds* is the (possibly narrowed)
    ``list_of_datasets`` value.
    """

    def _resolve(key: str) -> tuple[str, dict[str, Any], dict[str, Any], Any]:
        dl_cfg: dict[str, Any] = dataloaders_dict[key]["dataloader"]
        raw_ds = dl_cfg["dataset"]
        if not OmegaConf.is_config(raw_ds):
            raw_ds = OmegaConf.create(raw_ds)
        ds_cfg = cast(dict[str, Any], OmegaConf.to_container(raw_ds, resolve=True))
        return key, dl_cfg, ds_cfg, ds_cfg.get("list_of_datasets")

    if dataset_name and dataset_name in dataloaders_dict:
        key, dl_cfg, ds_cfg, inner = _resolve(dataset_name)
        return key, dl_cfg, ds_cfg, inner

    if dataset_name:
        for candidate_key in dataloaders_dict:
            _, dl_cfg, ds_cfg, inner = _resolve(candidate_key)
            if inner is not None and isinstance(inner, list):
                names = [e.get("name") for e in inner if isinstance(e, dict)]
                if dataset_name in names:
                    matched = [e for e in inner if isinstance(e, dict) and e.get("name") == dataset_name]
                    ds_cfg["list_of_datasets"] = [matched[0]]
                    inner = ds_cfg["list_of_datasets"]
                    logger.info(f"Narrowed list_of_datasets to single entry: {dataset_name!r}")
                    return candidate_key, dl_cfg, ds_cfg, inner

        available: dict[str, list[str | None]] = {}
        for k in dataloaders_dict:
            _, _, ds, lod = _resolve(k)
            if lod is not None and isinstance(lod, list):
                available[k] = [e.get("name") for e in lod if isinstance(e, dict)]
            else:
                available[k] = []
        raise ValueError(
            f"dataset={dataset_name!r} not found in any dataloader. "
            f"Available dataloaders and their datasets: {available}"
        )

    key, dl_cfg, ds_cfg, inner = _resolve(next(iter(dataloaders_dict)))
    return key, dl_cfg, ds_cfg, inner


class DatasetArgs(pydantic.BaseModel):
    """Arguments controlling which dataset to load and how to prepare it.

    The Hydra ``config_file`` and ``experiment`` used to resolve the dataset
    are supplied separately (typically from the setup args) so that CLI
    flag names never collide with the model's setup arguments.
    """

    model_config = pydantic.ConfigDict(extra="forbid", use_attribute_docstrings=True)

    dataset_experiment: str = ""
    """Hydra experiment for the dataset config. If empty, the setup experiment is used."""

    dataset: str = ""
    """Dataset selector (matched against dataloader keys or list_of_datasets names)."""
    sample_name: str = ""
    """Override the dataset name used in sample paths (output directories, ground truth).
    Falls back to ``dataset`` if empty."""
    dataset_split: Split = "val"
    """Dataset split."""
    model_mode: ModelMode | Literal["joint", "vision"] = "joint"
    """Model mode(s) to iterate / dispatch on. ``joint`` expands to all three action modes
    (forward_dynamics / inverse_dynamics / policy). ``vision`` dispatches the eval script to
    score pre-generated videos against a GT directory (no inference)."""

    num_samples: int | None = None
    """Maximum number of samples to load."""
    sample_stride: int = 1
    """Stride between sample indices."""

    gcs_path_map: dict[str, str] = {}
    """Maps default dataset root paths to S3 URIs.
    When the resolved config root(s) matches a key, the data is downloaded from
    the corresponding S3 URI and the root is overridden with the local cache path."""
    gcs_credentials: str = DEFAULT_GCS_CREDENTIALS
    """Path to GCS credentials JSON file."""
    root_override: str = ""
    """Override the dataset root path without downloading. Takes precedence over gcs_path_map."""
    gcs_root_override: str = ""
    """S3 URI to download and use as the dataset root. Takes precedence over gcs_path_map
    but unlike root_override, triggers a download first."""
    force_download: bool = False
    """Force re-download even if local dataset directory already exists."""
    dataset_name_override: str = ""
    """Override the inner dataset's name field after narrowing."""
    dataset_kwargs: dict[str, Any] = {}
    """Extra keyword arguments applied to the inner dataset config before instantiation.
    Useful for overriding constructor parameters like ``snap_to_subtask`` or ``max_subtasks_per_episode``."""
    cache_dir: Annotated[ResolvedPath | None, tyro.conf.arg(aliases=("-c",))] = None
    """Local cache directory for GCS downloads. Required when ``gcs_path_map`` triggers a download."""

    config_file: ResolvedFilePath | None = None
    """Path to a serialized YAML dataset config file."""

    @property
    def dataset_label(self) -> str:
        """Label used for sample output paths; falls back to the selector when unset."""
        return self.sample_name or self.dataset


def create_dataset(
    args: DatasetArgs,
    config_args: SetupArgs | None = None,
) -> DatasetSamples:
    """Load a dataset and return a lazy ``DatasetSamples`` iterator of ``(sample_args, data_batch)`` pairs.

    Two config sources are supported (mirroring checkpoint handling):

    * **config store:** provide ``config_args`` with
      ``config_file`` + ``experiment`` to resolve the config via Hydra.
    * **YAML:** set ``args.dataset_yaml`` to a serialized YAML config

    Args:
        args: Dataset selection and download parameters.
        config_args: Hydra config arguments (internal config store).
    """

    # --- load config ----------------------------------------------------------
    if args.config_file is not None:
        config = deserialize_config_dict(args.config_file)
    elif config_args is not None:
        if args.dataset_experiment:
            config_args = config_args.model_copy(update={"experiment": args.dataset_experiment})
        # load_config() only handles .py module configs; YAML/JSON use the dict path.
        if config_args.config_file_type == ConfigFileType.MODULE:
            config = config_args.load_config()
        else:
            config = deserialize_config_dict(Path(config_args.config_file))
    else:
        raise ValueError("Provide 'config_args' or set 'dataset_yaml'")

    def _cfg(key: str) -> Any:
        return config[key] if isinstance(config, dict) else getattr(config, key)

    _train = _cfg("dataloader_train")
    _val = _cfg("dataloader_val")

    if args.dataset_split == "train":
        dataloaders_config = _train
    elif args.dataset_split in ("val", "full"):
        dataloaders_config = _val if _val is not None else _train
    else:
        assert_never(args.dataset_split)
    assert dataloaders_config is not None, "Neither dataloader_val nor dataloader_train found in config"

    dataloaders_dict: dict[str, Any] = dataloaders_config["dataloaders"]
    dataset_key, _, dataset_config, inner_ds = _select_dataloader(dataloaders_dict, args.dataset)
    logger.info(f"Selected dataloader {dataset_key!r} for dataset={args.dataset!r}")

    if isinstance(inner_ds, list):
        if len(inner_ds) == 1 and isinstance(inner_ds[0], dict):
            inner_ds = inner_ds[0]
        else:
            raise ValueError("Use --dataset to select a single dataset entry by name.")
    resolution: str | None = inner_ds.get("resolution") if isinstance(inner_ds, dict) else None
    # wrap_dataset also accepts a global resolution that individual entries can override
    if resolution is None:
        resolution: str | None = dataset_config.get("resolution")

    # --- resolve the inner dataset target -------------------------------------
    # Dataset config fields may be nested inside inner_ds["dataset"] when the
    # full dataset_entry wrapper dict is preserved (after _select_dataloader narrowing),
    # or live directly on inner_ds when the raw dataset dict is returned.
    _inner = inner_ds
    if isinstance(inner_ds, dict) and isinstance(inner_ds.get("dataset"), dict):
        _inner = inner_ds["dataset"]

    # --- apply dataset_name override ------------------------------------------
    if args.dataset_name_override and isinstance(_inner, dict):
        for key in ("dataset_name", "name"):
            if key in _inner:
                logger.info(f"Overriding inner dataset {key}={_inner[key]!r} -> {args.dataset_name_override!r}")
                _inner[key] = args.dataset_name_override
                break

    # --- resolve root via overrides / gcs_path_map and download ----------
    root_override: str | list[str] = ""
    config_root = _inner.get("root") if isinstance(_inner, dict) else None
    if args.root_override:
        root_override = args.root_override
    elif args.gcs_root_override:
        if args.cache_dir is None:
            raise ValueError("cache_dir is required when gcs_root_override triggers a download")
        args.cache_dir.mkdir(parents=True, exist_ok=True)
        root_override = str(
            download_from_gcs_uri(
                args.gcs_root_override,
                args.cache_dir,
                gcs_credentials=args.gcs_credentials,
                force_download=args.force_download,
            )
        )
        logger.info(f"Downloaded dataset from {args.gcs_root_override} to {root_override}")
    elif (config_root is not None) and args.gcs_path_map:
        root_is_sequence = False
        if isinstance(config_root, str):
            orig_roots: list[str] = [config_root]
        elif isinstance(config_root, (list, tuple)):
            root_is_sequence = True
            orig_roots = list(config_root)
        else:
            raise ValueError(f"Unexpected root type: {type(config_root)}")

        # Populate the cache directory.
        if args.cache_dir is None:
            raise ValueError("cache_dir is required when gcs_path_map triggers a download")
        args.cache_dir.mkdir(parents=True, exist_ok=True)

        for i, orig_root in enumerate(orig_roots):
            if orig_root.startswith("s3://"):
                # Already an S3 URI — use directly, no mapping needed.
                logger.info(f"Root {orig_root} is already an S3 URI, skipping gcs_path_map")
                continue
            uri = args.gcs_path_map.get(orig_root.rstrip("/"))
            if uri is None:
                raise ValueError(f"No entry found in gcs_path_map for root='{orig_root.rstrip('/')}'")

            orig_roots[i] = str(
                download_from_gcs_uri(
                    uri,
                    args.cache_dir,
                    gcs_credentials=args.gcs_credentials,
                    force_download=args.force_download,
                    dataset_name_override=args.dataset_name_override,
                )
            )

        if orig_roots:
            root_override = orig_roots if root_is_sequence else orig_roots[0]

    if root_override:
        override_dataset_root(dataset_config, root_override)

    # --- override split -------------------------------------------------------
    if isinstance(_inner, dict) and "split" in _inner:
        _inner["split"] = args.dataset_split
        logger.info(f"Overriding dataset split to {args.dataset_split!r}")

    if isinstance(_inner, dict) and "is_val" in _inner:
        is_val = args.dataset_split == "val"
        _inner["is_val"] = is_val
        logger.info(f"Overriding dataset is_val to {is_val!r}")

    # --- disable training-only augmentations for val and 'full' --------------------------
    if args.dataset_split in ("val", "full") and "cfg_dropout_rate" in dataset_config:
        dataset_config["cfg_dropout_rate"] = 0.0
        logger.info("Overriding cfg_dropout_rate to 0.0 for val export")

    # --- disable shuffle for val dataset export -------------------------------
    if args.dataset_split == "val" and isinstance(_inner, dict) and "shuffle" in _inner:
        _inner["shuffle"] = False
        logger.info("Overriding dataset shuffle to False for val export")

    # --- apply dataset_kwargs overrides --------------------------------------
    if args.dataset_kwargs and isinstance(_inner, dict):
        for key, value in args.dataset_kwargs.items():
            logger.info(f"Overriding dataset {key}={_inner.get(key)!r} -> {value!r}")
            _inner[key] = value

    # --- apply gcs credentials override (internal-only)--------------------
    if isinstance(_inner, dict) and "credential_path" in _inner:
        _inner["credential_path"] = args.gcs_credentials
        logger.info(f"Overriding dataset credential_path to {_inner['credential_path']!r}")

    # --- instantiate dataset --------------------------------------------------
    logger.info(f"Instantiating dataset with config: {dataset_config}")
    dataset: Dataset = hydra.utils.instantiate(dataset_config)
    dataset_size = len(dataset)  # type: ignore

    transform = getattr(dataset, "transform", None)
    if isinstance(dataset, IterableDataset):
        inner = getattr(dataset, "dataset", None)
        raw = getattr(inner, "dataset", inner)
        if raw is not None and hasattr(raw, "__getitem__"):
            dataset = raw

    if hasattr(dataset, "_register_sources") and hasattr(dataset, "_all_shard_roots") and dataset_size == 0:
        dataset._register_sources()
        dataset_size = len(dataset)  # type: ignore

    def _create_dataset_samples(ds: Dataset, m: list[str], ids: list[int]) -> DatasetSamples:
        sample_overrides_data = config_args.sample_overrides.model_dump(exclude_none=True) if config_args else {}
        if isinstance(ds, IterableDataset):
            return IterableDatasetSamples(ds, m, ids, transform, resolution, args.dataset_label, sample_overrides_data)
        return MapDatasetSamples(ds, m, ids, transform, resolution, args.dataset_label, sample_overrides_data)

    # --- compute sample indices -----------------------------------------------
    sample_ids = list(range(0, dataset_size, args.sample_stride))
    if args.num_samples:
        sample_ids = sample_ids[: args.num_samples]

    modes = list(ALL_MODEL_MODES) if args.model_mode == "joint" else [args.model_mode]

    # The transform's text tokenizer produces text_token_ids for training only;
    # during inference the model re-tokenizes from the caption string (see
    # OmniMoTModel._get_inference_text_tokens).  Disable it to avoid tokenizer
    # compatibility issues in environments where apply_chat_template returns a dict.
    if transform is not None and hasattr(transform, "text_tokenizer"):
        transform.text_tokenizer = None

    logger.info(f"Created lazy iterator for {len(modes) * len(sample_ids)} samples across modes {modes}")
    return _create_dataset_samples(dataset, modes, sample_ids)