File size: 6,883 Bytes
b152c62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python
"""Shared direct-to-webdataset writer for converters whose sources are NOT
LeRobot repos (rosbags, hdf5, processed nav parquet). Skips the intermediate
shard-builder layout and emits exactly what pack_wds.py emits:

    shard-%05d.tar      __key__ / cam0.jpg / cam1.jpg / meta.npz
    manifest.json       {name, samples, chunk, embodiment_id, norm_stats, shards, ...}
    tasks.json          {task_index(str) -> instruction}

Normalization contract matches pack_wds.py: state/action are normalized HERE
with per-dataset mean/std computed in the converter's cheap first pass (no
image decoding), and the applied stats land in manifest["norm_stats"].

Converters use:
    stats = StatsAccum();  stats.add(states, actions) per episode;  st = stats.finalize()
    w = WdsWriter(out, name, st, chunk=50, embodiment_id=...)
    w.add_episode(ep_idx, states, actions, task_index, cam0_jpegs, cam1_jpegs=None)
    w.close(tasks={idx: text}, manifest_extra={...})
"""

from __future__ import annotations

import io
import json
from pathlib import Path

import numpy as np


def encode_jpeg(arr: np.ndarray, size: int = 256, quality: int = 92) -> bytes:
    """Same convention as build_shards_v2.encode_jpeg: plain squash resize."""
    from PIL import Image

    img = Image.fromarray(arr)
    if img.size != (size, size):
        img = img.resize((size, size), Image.BILINEAR)
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=quality, subsampling=0)
    return buf.getvalue()


class StatsAccum:
    """Pooled mean/std over frames, same math as shards.py stats pooling.
    Separate counters: callers may feed different numbers of state rows and
    action rows (e.g. VAMOS feeds T states but T*chunk trajectory points)."""

    def __init__(self):
        self.s_n = self.a_n = 0
        self.s_sum = self.s_sq = self.a_sum = self.a_sq = None

    def add(self, states: np.ndarray, actions: np.ndarray):
        states = np.asarray(states, np.float64)
        actions = np.asarray(actions, np.float64)
        if self.s_sum is None:
            self.s_sum = states.sum(0); self.s_sq = (states ** 2).sum(0)
            self.a_sum = actions.sum(0); self.a_sq = (actions ** 2).sum(0)
        else:
            self.s_sum += states.sum(0); self.s_sq += (states ** 2).sum(0)
            self.a_sum += actions.sum(0); self.a_sq += (actions ** 2).sum(0)
        self.s_n += len(states)
        self.a_n += len(actions)

    def finalize(self) -> dict:
        sn, an = max(1, self.s_n), max(1, self.a_n)
        sm = self.s_sum / sn
        am = self.a_sum / an
        sv = np.clip(self.s_sq / sn - sm ** 2, 0, None)
        av = np.clip(self.a_sq / an - am ** 2, 0, None)
        return {
            "observation.state": {"mean": sm.tolist(), "std": np.sqrt(sv).tolist()},
            "action": {"mean": am.tolist(), "std": np.sqrt(av).tolist()},
            "count": int(self.s_n),
        }


class WdsWriter:
    def __init__(self, out: Path, name: str, stats: dict, chunk: int = 50,
                 embodiment_id: int = 0, maxcount: int = 3000):
        import webdataset as wds

        self.out = Path(out)
        self.out.mkdir(parents=True, exist_ok=True)
        self.name, self.chunk, self.embodiment_id = name, chunk, embodiment_id
        self.stats = stats
        self.s_mean = np.asarray(stats["observation.state"]["mean"], np.float32)
        self.s_std = np.clip(np.asarray(stats["observation.state"]["std"], np.float32), 1e-6, None)
        self.a_mean = np.asarray(stats["action"]["mean"], np.float32)
        self.a_std = np.clip(np.asarray(stats["action"]["std"], np.float32), 1e-6, None)
        self.sink = wds.ShardWriter(str(self.out / "shard-%05d.tar"),
                                    maxcount=maxcount, verbose=0)
        self.n = 0
        self.episodes = 0

    def add_episode(self, ep_idx: int, states, actions, task_index,
                    cam0_jpegs: list, cam1_jpegs: list | None = None,
                    action_chunks: np.ndarray | None = None):
        """states f32[T,S], actions f32[T,A], cam0_jpegs list[bytes] of length T.

        task_index: one int for the whole episode, or an int array [T] when the
        source labels every frame (VAMOS rows). action_chunks f32[T, chunk, A]
        overrides the default consecutive-future chunking (used when each frame
        carries its own precomputed trajectory).
        """
        states = (np.asarray(states, np.float32) - self.s_mean) / self.s_std
        actions = (np.asarray(actions, np.float32) - self.a_mean) / self.a_std
        T = len(states)
        assert len(cam0_jpegs) == T, f"ep {ep_idx}: {len(cam0_jpegs)} jpegs vs {T} frames"
        task_per_frame = (np.full(T, task_index, np.int32)
                          if np.isscalar(task_index) else np.asarray(task_index, np.int32))
        S, A, CH = states.shape[1], actions.shape[1], self.chunk
        for f in range(T):
            if action_chunks is not None:
                chunk = ((np.asarray(action_chunks[f], np.float32) - self.a_mean)
                         / self.a_std)
                is_pad = np.zeros(CH, bool)
            else:
                avail = min(CH, T - f)
                chunk = np.empty((CH, A), np.float32)
                chunk[:avail] = actions[f : f + avail]
                if avail < CH:
                    chunk[avail:] = actions[f + avail - 1]
                is_pad = np.zeros(CH, bool)
                is_pad[avail:] = True
            buf = io.BytesIO()
            np.savez(buf, state=states[f], action_chunk=chunk, action_is_pad=is_pad,
                     action_dim=np.int32(A), state_dim=np.int32(S),
                     task_index=task_per_frame[f],
                     embodiment_id=np.int32(self.embodiment_id))
            cam1 = cam1_jpegs[f] if cam1_jpegs else b""
            self.sink.write({"__key__": f"{self.name}_{ep_idx:06d}_{f:05d}",
                             "cam0.jpg": cam0_jpegs[f], "cam1.jpg": cam1,
                             "meta.npz": buf.getvalue()})
            self.n += 1
        self.episodes += 1

    def close(self, tasks: dict, manifest_extra: dict | None = None):
        self.sink.close()
        (self.out / "tasks.json").write_text(
            json.dumps({str(k): v for k, v in tasks.items()}, ensure_ascii=False))
        (self.out / "manifest.json").write_text(json.dumps({
            **(manifest_extra or {}),
            "name": self.name, "samples": self.n, "chunk": self.chunk,
            "episodes": self.episodes,
            "embodiment_id": self.embodiment_id,
            "norm_stats": self.stats,
            "shards": sorted(p.name for p in self.out.glob("shard-*.tar")),
        }, indent=2))
        print(f"{self.name}: {self.n:,} семплов, {self.episodes} эпизодов -> "
              f"{len(list(self.out.glob('shard-*.tar')))} тарболов в {self.out}")