openral-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openral_cli/__init__.py +6 -0
- openral_cli/_hf_publish.py +125 -0
- openral_cli/_rskill_doc_validator.py +562 -0
- openral_cli/_rskill_intel.py +334 -0
- openral_cli/_rskill_readme.py +217 -0
- openral_cli/_rskill_scaffolder.py +267 -0
- openral_cli/autodetect.py +464 -0
- openral_cli/check.py +397 -0
- openral_cli/collision.py +369 -0
- openral_cli/dataset.py +438 -0
- openral_cli/deploy_sim.py +2664 -0
- openral_cli/install.py +422 -0
- openral_cli/main.py +4108 -0
- openral_cli/prompt.py +128 -0
- openral_cli/py.typed +0 -0
- openral_cli/robot.py +301 -0
- openral_cli-0.1.0.dist-info/METADATA +33 -0
- openral_cli-0.1.0.dist-info/RECORD +20 -0
- openral_cli-0.1.0.dist-info/WHEEL +4 -0
- openral_cli-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Per-family scaffold defaults and HF Hub config introspection.
|
|
2
|
+
|
|
3
|
+
This module powers the two ``ral skill new`` UX upgrades:
|
|
4
|
+
|
|
5
|
+
- ``--family <act|smolvla|pi05|xvla|diffusion>`` — drives manifest
|
|
6
|
+
defaults so a freshly-scaffolded ACT skill doesn't ship a
|
|
7
|
+
pi0.5-shaped baseline that the user then has to rewrite by hand.
|
|
8
|
+
- ``--from-hf <owner/repo>`` — fetches the checkpoint's ``config.json``
|
|
9
|
+
(and ``policy_preprocessor.json`` when present) from the Hub, infers
|
|
10
|
+
the policy family, chunk size, image-feature names, and proprio /
|
|
11
|
+
action dims, and folds those into the scaffold patch. Bypasses the
|
|
12
|
+
family menu when present.
|
|
13
|
+
|
|
14
|
+
Both paths produce a `RSkillPatch` dict that
|
|
15
|
+
`openral_cli._rskill_scaffolder.scaffold_rskill` applies on top
|
|
16
|
+
of the on-disk template, so the rest of the scaffolder stays oblivious
|
|
17
|
+
to family-specific knobs.
|
|
18
|
+
|
|
19
|
+
No mocks, no network unless ``--from-hf`` is requested.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from typing import Any, Literal, TypedDict
|
|
25
|
+
|
|
26
|
+
RSkillFamily = Literal["act", "smolvla", "pi05", "xvla", "diffusion"]
|
|
27
|
+
|
|
28
|
+
#: Min ``shape`` length for HxW extraction from an ``input_features``
|
|
29
|
+
#: entry. Anything shorter triggers the template-default fallback.
|
|
30
|
+
_CHW_DIMS = 2
|
|
31
|
+
|
|
32
|
+
#: The five families OpenRAL ships sim policy adapters for. Mirrors
|
|
33
|
+
#: the keys of :data:`openral_sim.registry.POLICIES` minus the mock
|
|
34
|
+
#: entries (``zero`` / ``random``) which are scene-side aids, not
|
|
35
|
+
#: packageable skills.
|
|
36
|
+
RSKILL_FAMILIES: tuple[RSkillFamily, ...] = ("act", "smolvla", "pi05", "xvla", "diffusion")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RSkillPatch(TypedDict, total=False):
|
|
40
|
+
"""Subset of manifest fields the scaffolder will overlay on the template.
|
|
41
|
+
|
|
42
|
+
Empty / missing keys mean "leave the template value untouched". The
|
|
43
|
+
scaffolder applies the patch *after* the rename / license rewrite so
|
|
44
|
+
keys that come from CLI flags (name, license, embodiment_tags,
|
|
45
|
+
weights_uri) stay authoritative.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_family: str
|
|
49
|
+
chunk_size: int
|
|
50
|
+
quantization: dict[str, Any]
|
|
51
|
+
latency_budget: dict[str, float]
|
|
52
|
+
min_vram_gb: dict[str, float] | None
|
|
53
|
+
n_action_steps: int | None
|
|
54
|
+
image_preprocessing: dict[str, Any] | None
|
|
55
|
+
state_contract: dict[str, Any] | None
|
|
56
|
+
# Per-checkpoint action contract. Required for any rSkill
|
|
57
|
+
# that wants to write through the dataset bridge — the bridge reads
|
|
58
|
+
# ``action_contract.dim`` to bind the LeRobot v3 ``action`` feature
|
|
59
|
+
# shape (cf. ``state_contract`` for ``observation.state``).
|
|
60
|
+
action_contract: dict[str, Any] | None
|
|
61
|
+
sensors_required: list[dict[str, Any]]
|
|
62
|
+
weights_uri: str
|
|
63
|
+
source_repo: str
|
|
64
|
+
description: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def family_defaults(family: RSkillFamily) -> RSkillPatch:
|
|
68
|
+
"""Return the manifest patch for a given family.
|
|
69
|
+
|
|
70
|
+
Numbers match the published reference manifests under ``rskills/``
|
|
71
|
+
(``act-aloha``, ``smolvla-libero``, ``pi05-libero-int8``, ``xvla-libero``,
|
|
72
|
+
``diffusion-pusht``). Keeping them in one place avoids drift between
|
|
73
|
+
the scaffolder and the in-tree reference skills.
|
|
74
|
+
"""
|
|
75
|
+
if family == "act":
|
|
76
|
+
# ACT (Zhao et al., 2023) — light ResNet-18 backbone, chunk=100,
|
|
77
|
+
# plain chunked replay. fp32 only (the published checkpoints
|
|
78
|
+
# were not trained with bf16 norm stats).
|
|
79
|
+
return {
|
|
80
|
+
"model_family": "act",
|
|
81
|
+
"chunk_size": 100,
|
|
82
|
+
"quantization": {"dtype": "fp32", "backend": "pytorch"},
|
|
83
|
+
"latency_budget": {
|
|
84
|
+
"per_chunk_ms": 100.0,
|
|
85
|
+
"warmup_ms": 5000.0,
|
|
86
|
+
"load_ms": 15000.0,
|
|
87
|
+
},
|
|
88
|
+
"min_vram_gb": None,
|
|
89
|
+
"n_action_steps": None,
|
|
90
|
+
"image_preprocessing": None,
|
|
91
|
+
"state_contract": None,
|
|
92
|
+
}
|
|
93
|
+
if family == "smolvla":
|
|
94
|
+
# SmolVLA paper config — chunk=16, bf16 on a desktop GPU.
|
|
95
|
+
return {
|
|
96
|
+
"model_family": "smolvla",
|
|
97
|
+
"chunk_size": 16,
|
|
98
|
+
"quantization": {"dtype": "bf16", "backend": "pytorch"},
|
|
99
|
+
"latency_budget": {
|
|
100
|
+
"per_chunk_ms": 150.0,
|
|
101
|
+
"warmup_ms": 8000.0,
|
|
102
|
+
"load_ms": 30000.0,
|
|
103
|
+
},
|
|
104
|
+
"min_vram_gb": None,
|
|
105
|
+
"n_action_steps": None,
|
|
106
|
+
}
|
|
107
|
+
if family == "pi05":
|
|
108
|
+
# Physical Intelligence π0.5 — 3B PaliGemma backbone, bf16,
|
|
109
|
+
# chunk=50 with half-chunk replan (n_action_steps=25). Keeps the
|
|
110
|
+
# min_vram_gb block so `openral doctor` can warn on 8 GB GPUs.
|
|
111
|
+
return {
|
|
112
|
+
"model_family": "pi05",
|
|
113
|
+
"chunk_size": 50,
|
|
114
|
+
"quantization": {"dtype": "bf16", "backend": "pytorch"},
|
|
115
|
+
"latency_budget": {
|
|
116
|
+
"per_chunk_ms": 200.0,
|
|
117
|
+
"warmup_ms": 15000.0,
|
|
118
|
+
"load_ms": 60000.0,
|
|
119
|
+
},
|
|
120
|
+
"min_vram_gb": {"fp32": 14.0, "bf16": 7.0},
|
|
121
|
+
"n_action_steps": 25,
|
|
122
|
+
}
|
|
123
|
+
if family == "xvla":
|
|
124
|
+
# xVLA (Florence-2 backbone). Same LIBERO 8-D state contract,
|
|
125
|
+
# but action space is 20-D internally (collapsed to 7-D by the
|
|
126
|
+
# adapter's postprocessor).
|
|
127
|
+
return {
|
|
128
|
+
"model_family": "xvla",
|
|
129
|
+
"chunk_size": 30,
|
|
130
|
+
"quantization": {"dtype": "bf16", "backend": "pytorch"},
|
|
131
|
+
"latency_budget": {
|
|
132
|
+
"per_chunk_ms": 200.0,
|
|
133
|
+
"warmup_ms": 10000.0,
|
|
134
|
+
"load_ms": 30000.0,
|
|
135
|
+
},
|
|
136
|
+
"min_vram_gb": {"fp32": 10.0, "bf16": 5.0},
|
|
137
|
+
"n_action_steps": None,
|
|
138
|
+
}
|
|
139
|
+
if family == "diffusion":
|
|
140
|
+
# Diffusion Policy — single camera, chunk=16, fp32 CPU-friendly.
|
|
141
|
+
return {
|
|
142
|
+
"model_family": "diffusion",
|
|
143
|
+
"chunk_size": 16,
|
|
144
|
+
"quantization": {"dtype": "fp32", "backend": "pytorch"},
|
|
145
|
+
"latency_budget": {
|
|
146
|
+
"per_chunk_ms": 100.0,
|
|
147
|
+
"warmup_ms": 3000.0,
|
|
148
|
+
"load_ms": 10000.0,
|
|
149
|
+
},
|
|
150
|
+
"min_vram_gb": None,
|
|
151
|
+
"n_action_steps": 8,
|
|
152
|
+
"image_preprocessing": None,
|
|
153
|
+
"state_contract": None,
|
|
154
|
+
}
|
|
155
|
+
raise ValueError(f"unknown skill family: {family!r}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
_CONFIG_TYPE_TO_FAMILY: dict[str, RSkillFamily] = {
|
|
159
|
+
"act": "act",
|
|
160
|
+
"smolvla": "smolvla",
|
|
161
|
+
"pi05": "pi05",
|
|
162
|
+
"xvla": "xvla",
|
|
163
|
+
"diffusion": "diffusion",
|
|
164
|
+
"diffusion_policy": "diffusion",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def introspect_hf(
|
|
169
|
+
repo_id: str, *, default_family: RSkillFamily | None = None
|
|
170
|
+
) -> tuple[RSkillFamily, RSkillPatch]:
|
|
171
|
+
"""Probe a HF Hub repo for ``config.json`` and derive a scaffold patch.
|
|
172
|
+
|
|
173
|
+
Builds on `family_defaults` — the introspected fields
|
|
174
|
+
(chunk_size, sensors, state_contract, weights_uri) override the
|
|
175
|
+
family baseline so the scaffold lands as close to the actual
|
|
176
|
+
checkpoint contract as possible.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
repo_id: Hub identifier, e.g. ``"Deepkar/libero-test-act"`` or
|
|
180
|
+
``"hf://Deepkar/libero-test-act"`` (the ``hf://`` prefix is
|
|
181
|
+
tolerated for symmetry with ``weights_uri``).
|
|
182
|
+
default_family: Family to fall back to when ``config.json``
|
|
183
|
+
``type`` field is missing or unrecognized. ``None`` raises
|
|
184
|
+
on an unknown type.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
``(family, patch)`` — the inferred family and a manifest patch
|
|
188
|
+
ready for the scaffolder. ``patch["weights_uri"]`` and
|
|
189
|
+
``patch["source_repo"]`` are pinned to ``hf://<repo_id>``.
|
|
190
|
+
|
|
191
|
+
Raises:
|
|
192
|
+
ValueError: ``config.json`` cannot be fetched, doesn't parse,
|
|
193
|
+
or has an unknown ``type`` and no ``default_family`` was
|
|
194
|
+
given.
|
|
195
|
+
"""
|
|
196
|
+
repo_id = repo_id.removeprefix("hf://")
|
|
197
|
+
config = _fetch_hf_json(repo_id, "config.json")
|
|
198
|
+
if not isinstance(config, dict):
|
|
199
|
+
raise ValueError(f"{repo_id}/config.json did not parse as a JSON object")
|
|
200
|
+
|
|
201
|
+
raw_type = str(config.get("type", "")).lower()
|
|
202
|
+
family = _CONFIG_TYPE_TO_FAMILY.get(raw_type, default_family)
|
|
203
|
+
if family is None:
|
|
204
|
+
valid = ", ".join(sorted(_CONFIG_TYPE_TO_FAMILY))
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{repo_id}/config.json declares type={raw_type!r}; recognized: {valid}. "
|
|
207
|
+
"Pass --family explicitly to override, or use a checkpoint with one "
|
|
208
|
+
"of the supported policy types."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
patch: RSkillPatch = dict(family_defaults(family)) # type: ignore[assignment]
|
|
212
|
+
if "chunk_size" in config:
|
|
213
|
+
patch["chunk_size"] = int(config["chunk_size"])
|
|
214
|
+
|
|
215
|
+
sensors = _sensors_from_input_features(config.get("input_features"))
|
|
216
|
+
if sensors:
|
|
217
|
+
patch["sensors_required"] = sensors
|
|
218
|
+
|
|
219
|
+
state_dim = _state_dim_from_input_features(config.get("input_features"))
|
|
220
|
+
if state_dim is not None:
|
|
221
|
+
patch["state_contract"] = {"dim": state_dim}
|
|
222
|
+
|
|
223
|
+
aliases = _aliases_from_input_features(config.get("input_features"))
|
|
224
|
+
if aliases:
|
|
225
|
+
ip = dict(patch.get("image_preprocessing") or {})
|
|
226
|
+
ip.setdefault("flip_180", False)
|
|
227
|
+
ip["aliases"] = aliases
|
|
228
|
+
patch["image_preprocessing"] = ip
|
|
229
|
+
|
|
230
|
+
patch["weights_uri"] = f"hf://{repo_id}"
|
|
231
|
+
patch["source_repo"] = f"hf://{repo_id}"
|
|
232
|
+
return family, patch
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _fetch_hf_json(repo_id: str, filename: str) -> Any: # noqa: ANN401 # reason: returns parsed JSON of arbitrary shape
|
|
236
|
+
"""Download a single JSON file from a HF Hub repo, return parsed body.
|
|
237
|
+
|
|
238
|
+
Uses ``huggingface_hub.hf_hub_download`` (same path the loader uses
|
|
239
|
+
for ``rskill.yaml``) so cache + auth behave identically. Raises on
|
|
240
|
+
network / parse error so the caller can surface a clear message.
|
|
241
|
+
"""
|
|
242
|
+
import json # noqa: PLC0415 # reason: keep top-level imports minimal
|
|
243
|
+
|
|
244
|
+
# reason: optional dep, only needed for --from-hf
|
|
245
|
+
try:
|
|
246
|
+
from huggingface_hub import hf_hub_download # noqa: PLC0415
|
|
247
|
+
except ImportError as exc:
|
|
248
|
+
raise ValueError(
|
|
249
|
+
"huggingface_hub is required for --from-hf; install with `uv sync`."
|
|
250
|
+
) from exc
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
local = hf_hub_download(repo_id=repo_id, filename=filename)
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
raise ValueError(f"could not fetch {repo_id}/{filename}: {exc}") from exc
|
|
256
|
+
|
|
257
|
+
with open(local) as f:
|
|
258
|
+
return json.load(f)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _sensors_from_input_features(input_features: Any) -> list[dict[str, Any]]: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
|
|
262
|
+
"""Build ``sensors_required`` from ``config.input_features``.
|
|
263
|
+
|
|
264
|
+
Only emits entries for keys that start with ``observation.images.``;
|
|
265
|
+
state / proprio features stay out (they're declared via
|
|
266
|
+
``state_contract`` instead). Each image feature becomes a
|
|
267
|
+
``camera<N>`` sensor on the scene side, with the checkpoint's
|
|
268
|
+
``image*`` / ``wrist_image`` key landing in
|
|
269
|
+
``image_preprocessing.aliases`` separately.
|
|
270
|
+
"""
|
|
271
|
+
if not isinstance(input_features, dict):
|
|
272
|
+
return []
|
|
273
|
+
sensors: list[dict[str, Any]] = []
|
|
274
|
+
counter = 0
|
|
275
|
+
for key, spec in input_features.items():
|
|
276
|
+
if not key.startswith("observation.images."):
|
|
277
|
+
continue
|
|
278
|
+
if not isinstance(spec, dict):
|
|
279
|
+
continue
|
|
280
|
+
shape = spec.get("shape") or [3, 224, 224]
|
|
281
|
+
# ``input_features`` shapes are CHW; the last two dims are HxW.
|
|
282
|
+
# Length < ``_CHW_DIMS`` means we couldn't parse the shape, so we
|
|
283
|
+
# fall back to the template's 224x224 minimum.
|
|
284
|
+
h = int(shape[-2]) if len(shape) >= _CHW_DIMS else 224
|
|
285
|
+
w = int(shape[-1]) if len(shape) >= _CHW_DIMS else 224
|
|
286
|
+
counter += 1
|
|
287
|
+
sensors.append(
|
|
288
|
+
{
|
|
289
|
+
"modality": "rgb",
|
|
290
|
+
"vla_feature_key": f"observation.images.camera{counter}",
|
|
291
|
+
"min_width": w,
|
|
292
|
+
"min_height": h,
|
|
293
|
+
}
|
|
294
|
+
)
|
|
295
|
+
return sensors
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _state_dim_from_input_features(input_features: Any) -> int | None: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
|
|
299
|
+
"""Pull the proprio state dim from ``observation.state.shape``."""
|
|
300
|
+
if not isinstance(input_features, dict):
|
|
301
|
+
return None
|
|
302
|
+
state = input_features.get("observation.state")
|
|
303
|
+
if not isinstance(state, dict):
|
|
304
|
+
return None
|
|
305
|
+
shape = state.get("shape")
|
|
306
|
+
if not isinstance(shape, (list, tuple)) or not shape:
|
|
307
|
+
return None
|
|
308
|
+
return int(shape[0])
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _aliases_from_input_features(input_features: Any) -> dict[str, str]: # noqa: ANN401 # reason: parsed-JSON shape varies per checkpoint
|
|
312
|
+
"""Map ``camera<N>`` source keys → checkpoint image-feature names.
|
|
313
|
+
|
|
314
|
+
Walks ``observation.images.<feature_name>`` entries in declaration
|
|
315
|
+
order and pairs them with the ``camera<N>`` slots emitted by
|
|
316
|
+
`_sensors_from_input_features`. The result is suitable for
|
|
317
|
+
``image_preprocessing.aliases`` — the in-tree adapters rename the
|
|
318
|
+
scene-side ``camera<N>`` keys into the checkpoint's input-feature
|
|
319
|
+
names at step time.
|
|
320
|
+
|
|
321
|
+
Returns an empty dict when the checkpoint's image keys already
|
|
322
|
+
match ``camera<N>`` (no rename needed).
|
|
323
|
+
"""
|
|
324
|
+
if not isinstance(input_features, dict):
|
|
325
|
+
return {}
|
|
326
|
+
image_keys = [k for k in input_features if k.startswith("observation.images.")]
|
|
327
|
+
aliases: dict[str, str] = {}
|
|
328
|
+
for i, key in enumerate(image_keys, start=1):
|
|
329
|
+
feature_name = key.removeprefix("observation.images.")
|
|
330
|
+
cam = f"camera{i}"
|
|
331
|
+
if feature_name == cam:
|
|
332
|
+
continue
|
|
333
|
+
aliases[cam] = feature_name
|
|
334
|
+
return aliases
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Build an HF model-card README for an rSkill from its manifest.
|
|
2
|
+
|
|
3
|
+
The ``rskill.yaml`` manifest is the single source of truth (CLAUDE.md §1.3). The
|
|
4
|
+
HF model-card **front-matter** (license, tags, ``pipeline_tag``, ``library_name``,
|
|
5
|
+
the ``base_model`` tree, ``datasets``) is DERIVED from it here so every published
|
|
6
|
+
repo — **private or public** — carries a consistent, discoverable model card
|
|
7
|
+
without hand-maintaining YAML front-matter across ~30 READMEs. The human-written
|
|
8
|
+
prose **body** is kept verbatim; only the front-matter is (re)generated.
|
|
9
|
+
|
|
10
|
+
This is the "best of both" reconciliation: the OpenRAL-specific tags + accurate
|
|
11
|
+
license posture that the in-tree READMEs already carried, unioned with the HF
|
|
12
|
+
model-card fields (``library_name``, ``pipeline_tag``, ``inference``,
|
|
13
|
+
``base_model`` / ``base_model_relation``, ``datasets``) that only some hand-edited
|
|
14
|
+
HF cards had — now emitted uniformly from the manifest.
|
|
15
|
+
|
|
16
|
+
Example:
|
|
17
|
+
>>> from openral_core.schemas import RSkillManifest
|
|
18
|
+
>>> m = RSkillManifest.from_yaml("rskills/act-aloha/rskill.yaml")
|
|
19
|
+
>>> card = build_rskill_readme(m, "# body\\n\\n## License\\nMIT.\\n")
|
|
20
|
+
>>> card.startswith("---\\n")
|
|
21
|
+
True
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
from openral_core.schemas import RSkillLicensePosture, RSkillManifest
|
|
32
|
+
|
|
33
|
+
__all__ = ["build_rskill_frontmatter", "build_rskill_readme", "render_frontmatter"]
|
|
34
|
+
|
|
35
|
+
# lerobot ``PolicyProcessorPipeline`` families — these load through lerobot, so the
|
|
36
|
+
# HF ``library_name`` widget should point there. Other families (gr00t / rldx run
|
|
37
|
+
# out-of-process; molmoact2 is a transformers custom-code model) don't.
|
|
38
|
+
_LEROBOT_FAMILIES = frozenset({"smolvla", "act", "pi05", "diffusion", "xvla"})
|
|
39
|
+
|
|
40
|
+
# License posture → (HF license identifier, optional license_name). HF only knows
|
|
41
|
+
# a fixed SPDX-ish set; anything non-standard maps to ``other`` + a descriptive
|
|
42
|
+
# ``license_name`` (mirrors the accurate posture the in-tree READMEs carried).
|
|
43
|
+
_LICENSE_MAP: dict[RSkillLicensePosture, tuple[str, str | None]] = {
|
|
44
|
+
RSkillLicensePosture.APACHE_2_0: ("apache-2.0", None),
|
|
45
|
+
RSkillLicensePosture.MIT: ("mit", None),
|
|
46
|
+
RSkillLicensePosture.BSD: ("bsd-3-clause", None),
|
|
47
|
+
RSkillLicensePosture.PERMISSIVE_RESEARCH: ("other", "permissive-research"),
|
|
48
|
+
RSkillLicensePosture.NVIDIA_NON_COMMERCIAL: ("other", "nvidia-non-commercial"),
|
|
49
|
+
RSkillLicensePosture.NVIDIA_OPEN_MODEL: ("other", "nvidia-open-model-license"),
|
|
50
|
+
RSkillLicensePosture.RLWRLD_NON_COMMERCIAL: ("other", "rlwrld-non-commercial"),
|
|
51
|
+
RSkillLicensePosture.PROPRIETARY: ("other", "proprietary"),
|
|
52
|
+
RSkillLicensePosture.UNKNOWN: ("other", None),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_NON_QUANTIZED_DTYPES = frozenset({"fp32", "bf16", "fp16", "float32", "float16"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _hf_repo(uri: str | None) -> str | None:
|
|
59
|
+
"""``hf://org/repo@sha`` → ``org/repo``; ``None``/non-hf → ``None``."""
|
|
60
|
+
if not uri or "://" not in uri:
|
|
61
|
+
return None
|
|
62
|
+
scheme, rest = uri.split("://", 1)
|
|
63
|
+
if scheme != "hf":
|
|
64
|
+
return None
|
|
65
|
+
return rest.split("@", 1)[0] or None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(?P<fm>.*?)\n---\n+", re.DOTALL)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _strip_frontmatter(body: str) -> str:
|
|
72
|
+
r"""Drop a leading ``---\\n…\\n---\\n`` YAML front-matter block if present."""
|
|
73
|
+
m = _FRONTMATTER_RE.match(body)
|
|
74
|
+
return body[m.end() :] if m else body.lstrip("\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _parse_frontmatter(body: str) -> dict[str, Any]:
|
|
78
|
+
"""Parse a leading YAML front-matter block into a dict (``{}`` if none/invalid)."""
|
|
79
|
+
m = _FRONTMATTER_RE.match(body)
|
|
80
|
+
if not m:
|
|
81
|
+
return {}
|
|
82
|
+
try:
|
|
83
|
+
loaded = yaml.safe_load(m.group("fm"))
|
|
84
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
85
|
+
except yaml.YAMLError:
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _dedup(seq: list[str]) -> list[str]:
|
|
90
|
+
seen: set[str] = set()
|
|
91
|
+
out: list[str] = []
|
|
92
|
+
for x in seq:
|
|
93
|
+
if x and x not in seen:
|
|
94
|
+
seen.add(x)
|
|
95
|
+
out.append(x)
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_rskill_frontmatter(manifest: RSkillManifest) -> dict[str, Any]:
|
|
100
|
+
"""Derive the HF model-card front-matter dict from an rSkill manifest.
|
|
101
|
+
|
|
102
|
+
Deterministic and pure (no network, no HF read), so the publisher builds the
|
|
103
|
+
same card whether the repo is private or public.
|
|
104
|
+
"""
|
|
105
|
+
kind = str(getattr(manifest.kind, "value", manifest.kind) or "")
|
|
106
|
+
family = manifest.model_family
|
|
107
|
+
family_s = str(getattr(family, "value", family)) if family else None
|
|
108
|
+
is_detector = kind == "detector"
|
|
109
|
+
is_lerobot = family_s in _LEROBOT_FAMILIES
|
|
110
|
+
|
|
111
|
+
fm: dict[str, Any] = {"language": ["en"]}
|
|
112
|
+
|
|
113
|
+
lic_id, lic_name = _LICENSE_MAP.get(manifest.license, ("other", None))
|
|
114
|
+
fm["license"] = lic_id
|
|
115
|
+
if lic_name:
|
|
116
|
+
fm["license_name"] = lic_name
|
|
117
|
+
|
|
118
|
+
if is_lerobot:
|
|
119
|
+
fm["library_name"] = "lerobot"
|
|
120
|
+
fm["pipeline_tag"] = "object-detection" if is_detector else "robotics"
|
|
121
|
+
|
|
122
|
+
tags = ["OpenRAL", "rskill"]
|
|
123
|
+
if family_s:
|
|
124
|
+
tags.append(family_s)
|
|
125
|
+
if is_lerobot:
|
|
126
|
+
tags.append("lerobot")
|
|
127
|
+
if kind == "vla":
|
|
128
|
+
tags.append("vision-language-action")
|
|
129
|
+
elif kind in {"ros_action", "ros_service"}:
|
|
130
|
+
tags += ["ros2", "moveit"] if "moveit" in manifest.name else ["ros2"]
|
|
131
|
+
elif is_detector:
|
|
132
|
+
tags += ["detector", "object-detection"]
|
|
133
|
+
q = manifest.quantization
|
|
134
|
+
if q is not None:
|
|
135
|
+
dt = str(getattr(q.dtype, "value", q.dtype))
|
|
136
|
+
if dt not in _NON_QUANTIZED_DTYPES:
|
|
137
|
+
tags.append("nf4" if dt == "int4" else dt)
|
|
138
|
+
tags.append("4-bit" if dt == "int4" else "8-bit" if dt == "int8" else "quantized")
|
|
139
|
+
tags += list(manifest.embodiment_tags)
|
|
140
|
+
fm["tags"] = _dedup(tags)
|
|
141
|
+
|
|
142
|
+
base = _hf_repo(getattr(manifest, "source_repo", None))
|
|
143
|
+
own = _hf_repo(manifest.weights_uri)
|
|
144
|
+
if base and base != own:
|
|
145
|
+
fm["base_model"] = [base]
|
|
146
|
+
# relation reflects what the packaging did to the upstream: quantized when
|
|
147
|
+
# a real quantization block is present, else a plain finetune/wrapper.
|
|
148
|
+
dtype = str(getattr(q.dtype, "value", q.dtype)) if q is not None else ""
|
|
149
|
+
is_quantized = q is not None and dtype not in _NON_QUANTIZED_DTYPES
|
|
150
|
+
fm["base_model_relation"] = "quantized" if is_quantized else "finetune"
|
|
151
|
+
|
|
152
|
+
ds = _hf_repo(getattr(manifest, "dataset_uri", None))
|
|
153
|
+
if ds:
|
|
154
|
+
fm["datasets"] = [ds]
|
|
155
|
+
|
|
156
|
+
# rSkills are packaging manifests, not HF-inference-servable checkpoints.
|
|
157
|
+
fm["inference"] = False
|
|
158
|
+
return fm
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_frontmatter(fm: dict[str, Any]) -> str:
|
|
162
|
+
"""Render a front-matter dict as a ``---``-fenced YAML block (stable order)."""
|
|
163
|
+
body = yaml.safe_dump(fm, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
164
|
+
return f"---\n{body}---\n"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def build_rskill_readme(manifest: RSkillManifest, body: str) -> str:
|
|
168
|
+
"""Return the full README = merged front-matter + the prose ``body``.
|
|
169
|
+
|
|
170
|
+
"Best of both": the manifest-derived model-card fields are authoritative, but
|
|
171
|
+
hand-curated extras already on ``body``'s front-matter are preserved — its
|
|
172
|
+
``tags`` are unioned onto the derived tags, and any scalar/list field the
|
|
173
|
+
derive did not emit (e.g. a curated ``datasets`` or ``license_name``) is kept.
|
|
174
|
+
Derived fields win on conflict so the card stays consistent with the manifest.
|
|
175
|
+
"""
|
|
176
|
+
derived = build_rskill_frontmatter(manifest)
|
|
177
|
+
existing = _parse_frontmatter(body)
|
|
178
|
+
if existing:
|
|
179
|
+
extra_tags = [t for t in existing.get("tags", []) or [] if isinstance(t, str)]
|
|
180
|
+
derived["tags"] = _dedup([*derived.get("tags", []), *extra_tags])
|
|
181
|
+
for k, v in existing.items():
|
|
182
|
+
if k != "tags" and k not in derived and v not in (None, "", []):
|
|
183
|
+
derived[k] = v
|
|
184
|
+
return f"{render_frontmatter(derived)}\n{_strip_frontmatter(body)}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _demo() -> None:
|
|
188
|
+
"""Runnable self-check: front-matter derives the expected best-of-both fields."""
|
|
189
|
+
root = Path(__file__).resolve()
|
|
190
|
+
while root != root.parent and not (root / "rskills").is_dir():
|
|
191
|
+
root = root.parent
|
|
192
|
+
m = RSkillManifest.from_yaml(str(root / "rskills" / "pi05-libero-nf4" / "rskill.yaml"))
|
|
193
|
+
fm = build_rskill_frontmatter(m)
|
|
194
|
+
assert fm["library_name"] == "lerobot", fm
|
|
195
|
+
assert fm["pipeline_tag"] == "robotics", fm
|
|
196
|
+
assert fm["license"] == "other" and fm["license_name"] == "permissive-research", fm
|
|
197
|
+
assert fm["base_model"] == ["lerobot/pi05_libero_finetuned_v044"], fm
|
|
198
|
+
assert fm["base_model_relation"] == "quantized", fm
|
|
199
|
+
assert "4-bit" in fm["tags"] and "OpenRAL" in fm["tags"], fm
|
|
200
|
+
assert fm["inference"] is False
|
|
201
|
+
# merge: hand-curated extra tags + local-only scalars survive; derived wins
|
|
202
|
+
card = build_rskill_readme(
|
|
203
|
+
m, "---\ntags: [OpenRAL, hand-tag]\ncustom_field: keep\n---\n\n# body\n\n## License\nx\n"
|
|
204
|
+
)
|
|
205
|
+
merged = _parse_frontmatter(card)
|
|
206
|
+
assert "hand-tag" in merged["tags"] and merged.get("custom_field") == "keep", merged
|
|
207
|
+
assert merged["library_name"] == "lerobot", "derived field lost in merge"
|
|
208
|
+
n_fences = card.count("---\n")
|
|
209
|
+
assert n_fences >= 2 and "\n# body" in card, n_fences # noqa: PLR2004 # reason: yaml front-matter has exactly two fences
|
|
210
|
+
# detector uses the object-detection pipeline
|
|
211
|
+
d = RSkillManifest.from_yaml(str(root / "rskills" / "rtdetr-coco-r18" / "rskill.yaml"))
|
|
212
|
+
assert build_rskill_frontmatter(d)["pipeline_tag"] == "object-detection"
|
|
213
|
+
print("ok: _rskill_readme self-check passed")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
_demo()
|