File size: 7,206 Bytes
0613ebe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# Training config for LingBot-VA on the real-world Franka "fruit_pick" dataset
# (data/fruit_pick_lerobot_v21 -- 100 episodes, 20149 frames, task "Lift the
# lid, put it aside, and pick the black plum.").
#
# Source: downloaded 2026-08-09 from https://huggingface.co/datasets/aabyaneh/fruit_pick
# as LeRobot codebase_version v3.0 (one shared video file per camera, not
# per-episode) -- LingBot-VA's own pipeline (extract_latents.py,
# LatentLeRobotDataset) has no v3.0 awareness (hardcodes revision="v2.1",
# opens per-episode mp4 paths directly), so this repo carries its own genuine
# v2.1 conversion at data/fruit_pick_lerobot_v21, produced by
# scripts/convert_fruit_pick_v3_to_v21.py (decodes both source videos
# sequentially, re-encodes per-episode via the official
# LeRobotDataset.create()/add_frame()/save_episode() writer API -- verified
# byte-identical state/action values and matching frame counts against the
# source for spot-checked episodes) + scripts/backfill_action_config.py (the
# official writer doesn't produce the action_config field extract_latents.py
# needs; same gap hit for place_cube_in_bowl, same fix).
#
# observation.state is already this repo's standard 8-dim eef_pos(3) +
# axisangle(3) + gripper(2) convention (fastwam_train's
# fix_fruit_pick_state_schema.py rewrote it from HF's native 15-dim state
# using the SAME quat2axisangle formula lift2lerobot's own converter uses --
# so this is consistent with lift_new/place_cube_bowl's state convention,
# not a new one). action is 7-dim delta_xyz + delta_rot(always 0) + grasp,
# same schema/mask as every other dataset here.
#
# Gripper convention DIFFERS from place_cube_bowl: fruit_pick's raw action
# column is natively {-1, +1} (verified: np.unique gives exactly [-1., 1.]),
# NOT place_cube_bowl's remapped {0.0=closed, 1.0=open}. This dataset was
# never passed through the (1-g)/2 HDF5-sourced remap (it came pre-converted
# from HF, not from raw HDF5), so the value is whatever the original
# collection tool logged -- which of {-1, +1} means open vs. closed has NOT
# been verified against a physical signal (no gripper_width channel was kept
# after the 15->8 dim state rewrite to cross-check against, unlike
# place_cube_bowl's verification). This does NOT affect training (norm_stat
# below is computed FROM THIS DATASET'S OWN actual range, not reused from
# place_cube_bowl, so the quantile normalization is internally consistent
# regardless of which polarity is "open") -- but get the real mapping
# confirmed before deploying any policy trained from this config.
#
# q01/q99 below are computed from ALL 100 episodes' actual action data
# (data/fruit_pick_lerobot_v21/data/chunk-000/*.parquet, verified byte-copy
# of the source's action column) -- there is no held-out split to exclude
# from the normalizer here (see below).
#
# NO validation split (user decision 2026-08-09, matching the FastWAM
# fruit_pick jobs: use the entire 100-episode dataset for training). Neither
# `num_train_episodes` nor `val_split` is set below -- wan_va/train.py's
# Trainer.__init__ getattr-defaults both to 0/falsy, landing on the
# `else: train_dataset = full_dataset; val_dataset = None` branch (verified
# by reading train.py directly) rather than risking an empty val Subset from
# num_train_episodes=100 (=total episodes).
import os
from copy import deepcopy

from easydict import EasyDict

from wan_va.configs.va_libero_cfg import va_libero_cfg

REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

WAN_INIT_PATH = os.environ.get("WAM_WAN_INIT", os.path.join(REPO_ROOT, "ckpts/lingbot_va_wan_init"))
LINGBOT_BASE_PATH = os.environ.get("WAM_LINGBOT_BASE", os.path.join(REPO_ROOT, "ckpts/lingbot-va-base"))
DATASET_PATH = os.environ.get(
    "WAM_FRUIT_PICK_DATASET",
    os.path.join(REPO_ROOT, "data/fruit_pick_lerobot_v21"),
)


def _make(init_path: str) -> EasyDict:
    cfg = EasyDict(__name__="Config: VA fruit_pick")
    cfg.update(deepcopy(va_libero_cfg))
    cfg.wan22_pretrained_model_name_or_path = init_path

    cfg.dataset_path = DATASET_PATH
    cfg.empty_emb_path = os.path.join(cfg.dataset_path, "empty_emb.pt")

    cfg.height = 256
    cfg.width = 256
    cfg.obs_cam_keys = ["observation.images.image", "observation.images.wrist_image"]

    cfg.action_dim = 30
    cfg.used_action_channel_ids = list(range(0, 7))
    inverse_ids = [len(cfg.used_action_channel_ids)] * cfg.action_dim
    for i, j in enumerate(cfg.used_action_channel_ids):
        inverse_ids[j] = i
    cfg.inverse_used_action_channel_ids = inverse_ids

    cfg.action_norm_method = "quantiles"
    # Computed 2026-08-09 from ALL 100 episodes of data/fruit_pick_lerobot_v21
    # (np.quantile(actions, 0.01/0.99, axis=0)) -- fruit_pick's own actual
    # range, NOT place_cube_bowl's numbers (different task, different scale).
    cfg.norm_stat = {
        "q01": [
            -0.0054, -0.01794857, -0.01162286, 0.0, 0.0, 0.0, -1.0,
        ] + [0.0] * 23,
        "q99": [
            0.0087696, 0.01789714, 0.0120096, 0.0, 0.0, 0.0, 1.0,
        ] + [0.0] * 23,
    }

    cfg.action_channel_names = ["dx", "dy", "dz", "drx", "dry", "drz", "gripper"]
    cfg.action_channel_groups = {"xyz": [0, 1, 2], "rotation": [3, 4, 5], "gripper": [6]}
    cfg.gripper_channel_index = 6  # raw units are {-1.0, 1.0}; polarity meaning unverified, see header

    # Same phase-2 fine-tune recipe as lift_new_base/place_cube_bowl_base (arXiv:2601.21998
    # "Post-Training Details" for adapting a robot-co-pretrained init to a real-world
    # platform): this is the same Franka platform, just a different task, so the same
    # recipe applies rather than the scratch-init 8000-step floor.
    cfg.enable_wandb = True
    cfg.load_worker = 8
    cfg.save_interval = 200
    cfg.val_interval = cfg.save_interval
    cfg.gc_interval = 50
    cfg.cfg_prob = 0.1
    cfg.learning_rate = 1e-5
    cfg.beta1, cfg.beta2 = 0.9, 0.95
    cfg.weight_decay = 1e-1
    cfg.warmup_steps = 10
    cfg.batch_size = 1
    # Global batch is the recipe-critical quantity (maintainers' guidance is 32-64 episodes;
    # place_cube_bowl/lift_new jobs ran at 80) -- derive grad_accum from the launch's GPU
    # count instead of hardcoding it, same as va_place_cube_bowl_cfg.py.
    _global_batch = int(os.environ.get("WAM_GLOBAL_BATCH", 32))
    _world_size = int(os.environ.get("WORLD_SIZE", 1))
    if _global_batch % (cfg.batch_size * _world_size) != 0:
        raise ValueError(
            f"WAM_GLOBAL_BATCH={_global_batch} is not divisible by "
            f"batch_size({cfg.batch_size}) x WORLD_SIZE({_world_size})"
        )
    cfg.gradient_accumulation_steps = _global_batch // (cfg.batch_size * _world_size)
    cfg.num_steps = int(
        os.environ.get("WAM_NUM_STEPS", 8000 if init_path == WAN_INIT_PATH else 3000)
    )

    # Optional weight warm-start; see va_place_cube_bowl_cfg.py's header for the exact
    # semantics (WEIGHTS ONLY, not a bit-exact resume).
    _resume_from = os.environ.get("WAM_RESUME_FROM")
    if _resume_from:
        cfg.resume_from = _resume_from

    return cfg


FRUIT_PICK_CONFIGS = {
    "fruit_pick_scratch": _make(WAN_INIT_PATH),
    "fruit_pick_base": _make(LINGBOT_BASE_PATH),
}