Write an adapter
An adapter runs in an eval between a benchmark and a policy that do not speak the same way: it turns the
benchmark’s observation into what the policy reads, and what the policy returns into the benchmark’s
action. You write it as two lists of transforms, each run in order. The official toy-adapter is a
whole one — seq init toy-adapter writes it into a folder of your own:
import seq # toy-benchmark observes {"arm_x": [x], "target": [t]} and takes one number a step; # toy-policy reads {"state": [x], "target": [t]} and returns {"delta": [dx]} @seq.adapter(name="toy-adapter") class ToyAdapter(seq.Adapter): policy_io = "own" # the policy has its own inputs and outputs obs_transforms = [ # the benchmark's observation → the policy's inputs seq.tf.rename({"arm_x": "state"}), ] action_transforms = [ # the policy's answer → the benchmark's action seq.tf.custom("delta_to_step", lambda out: [[float(out["delta"][0])]]), ]
obs_transformsstart from the benchmark’s observation — the dict its environment’sobserve()returns — and end at what the policy is sent.action_transformsstart from the policy’s answer and end at the benchmark’s action: a list of rows, one per step, each as long as the benchmark’saction_contractdeclares. The benchmark’sstep()is handed them one row at a time.policy_iosays which kind of policy it feeds (A preset or your own)."robot_arm", the default: the lastobs_transformsstep produces the preset’s cameras and state, which the platform packs with the task’s instruction, andaction_transformsstart from the policy’s chunk."own":obs_transformsproduce exactly the policy’s inputs — what itsclient.pydoes on a real robot — andaction_transformsstart from its answer as it returned it.
seq deploy toy-adapter/adapter.py registers it as toy-adapter@v1 — nothing is
built or run — and an eval names it:
seq eval run toy-policy --benchmark toy-benchmark --adapter toy-adapter. An adapter takes parameters
the way a policy does, through its __init__ (Parameters in an
eval); what it may import, its versions and how to pull one back are under
Adapters.
Check it before an eval
seq adapter check runs both lists without an eval and without a GPU, on samples of what each side
gives:
$ seq adapter check toy-adapter/adapter.py --benchmark toy-benchmark-cpu --policy toy-policy
adapter toy-adapter ToyAdapter (adapter:ToyAdapter) policy_io own
obs rename(arm_x->state)
action custom(delta_to_step)
benchmark toy-benchmark-cpu v1: {arm_x: 1 float64, target: 1 float64} (its registration check's first observation)
obs rename(arm_x->state): {arm_x: 1 float64, target: 1 float64} -> {target: 1 float64, state: 1 float64}
policy toy-policy v1: takes ['state', 'target'] (its io= note); the adapter gives ['state', 'target'] - ok
policy toy-policy returns {delta: 1 float64}
action custom(delta_to_step): {delta: 1 float64} -> 1x1 float64
-> 1x1 float64; the benchmark takes actions of 1 - ok
ok: the transform pipeline is well-formed, and it fits; nothing was run on the platform
With --benchmark, a sample shaped like the benchmark’s first observation — its keys,
shapes and dtypes, read when it was registered — goes through obs_transforms one step at a
time. With --policy — a deployment’s name, or a policy file of your own — what comes
out is compared with what the policy reads, and a sample of what the policy returns goes back through
action_transforms and is compared with the benchmark’s action. Every mismatch is printed, and
any one ends the command non-zero, so it can gate a CI job. The adapter can be a file or a registered
name@vN; a check against a benchmark or a policy is kept with that version for its page in the
console, and --no-record keeps nothing.
Write your own transform
When no built-in transform does a step, write it: seq.tf.custom(name, fn). fn takes the
value the list holds at that point and returns the next one.
- In
obs_transforms,fngets the observation dict as the steps before it left it, and returns a dict. - In
action_transforms, it gets the policy’s answer — a preset policy’s chunk, whosestepseach hold theirvalues, or its own outputs withpolicy_io = "own"— or what the step before returned. The last step returns the benchmark’s rows, one list of numbers a step:seq.tf.custom("to_sim", lambda chunk: [s.values for s in chunk.steps]). The official adapters (libero-openpiamong them) turn actions this way. namenames the step wherever the list is shown:seq adapter checkprints each step ascustom(name)with what it took and gave, and a step that fails by its name —obs step custom(joints_only) failed on {...}: KeyError: 'qpos'.- An error in
fnis raised as it is. At an eval’s first act, one inobs_transformsstops the eval as a mismatch:the adapter could not map the benchmark's observation, then your error.
import numpy as np # what the benchmark's image installs def joints_only(obs): # the observation dict → the next one return {**obs, "state": np.asarray(obs["qpos"], dtype=np.float32)[:7]} def to_rows(out): # the policy's outputs → the benchmark's rows return [[float(v) for v in row] for row in out["actions"]] obs_transforms = [seq.tf.custom("joints_only", joints_only), seq.tf.drop(["qpos"])] action_transforms = [seq.tf.custom("to_rows", to_rows)]
Built-in transforms
The common steps, each one line. Each takes the observation dict and returns the next one —
action_project, the policy’s chunk — and none changes a value it does not name.
| transform | what it does | when it fails |
|---|---|---|
seq.tf.rename({"old": "new"}) | renames keys; the values move untouched. A key the dict does not hold is skipped. | never |
seq.tf.reshape(key, shape) | reshapes one array (numpy’s
reshape), keeping its dtype and its element count | a shape of another element count:
reshape(state) changes element count 8->7; a reshape must preserve it. Give a shape whose
product is the array’s size. |
seq.tf.downcast(key, "float32") | casts one array to the dtype you name
(numpy’s astype) | a dtype numpy does not know raises numpy’s own error |
seq.tf.drop(["key", ...]) | removes keys, a camera the policy does not read say; one the dict does not hold is skipped | never |
seq.tf.resize(key, (h, w), mode="pad", filter="bilinear") | fits one image
(height × width × 3, uint8) to h × w with the code a robot
fits its frames with, byte for byte, so the benchmark’s frame reaches the policy as a camera’s would.
mode="pad" keeps the aspect ratio and centres the frame between black bars (openpi’s
resize_with_pad); "stretch" fills the box. filter is Pillow’s:
nearest, box, bilinear, hamming, bicubic or
lanczos. Neither has a default: write the ones the policy was trained with — its
camera’s resize and filter (The I/O
contract). | a mode or a filter not in those lists, when the file is imported (below) |
seq.tf.crop(key, (h, w), top=None, left=None) | cuts an h ×
w window out of one image and keeps its pixels as they are — nothing is resampled, so fit it
after with resize — from the centre, or from top, left when given.
A window past the frame’s edge is moved inside it; one larger than the frame keeps the whole
frame. | never |
seq.tf.action_project(space, space, select=[0, 1, 2]) | keeps, drops or reorders a
preset chunk’s dimensions within one action space, and returns the benchmark’s rows:
select is the dimensions to keep, from 0, in the order given (left out, every dimension as it
is). [0, 1, 2, 3, 4, 5, 6] drops the 8th. A change of space — joint to end effector,
absolute to delta — re-derives the numbers, which no built-in transform does: write it as
your own transform. | two different spaces, or an empty
select, when the file is imported (below); a dimension past a
row’s width raises IndexError at the check or the eval’s first act |
seq.tf.identity() | returns what it is given | never |
seq.tf.custom(name, fn) | your own step (above) | your function’s own error, raised as it is |
A key a transform names that the dict does not hold — reshape or downcast on a
missing key — raises Python’s KeyError for it.
When an adapter is refused
@seq.adapter reads the class as soon as its file is imported — by seq deploy,
seq adapter check or your own test — and refuses it there, before anything is registered:
| the error | what to change |
|---|---|
ToyAdapter: every entry of obs_transforms/action_transforms must be a seq.tf.* transform, got
function | wrap your function: seq.tf.custom("name", fn) |
ToyAdapter declares no transforms; an adapter needs obs_transforms and/or action_transforms
(a zero-introspection adapter is not accepted) | declare at least one list; a step that does
nothing is seq.tf.identity() |
@seq.adapter must decorate a subclass of seq.Adapter | class
ToyAdapter(seq.Adapter) |
seq.tf.resize mode must be one of ('pad', 'stretch'), got 'crop', or filter must be
one of ('nearest', 'box', 'bilinear', 'hamming', 'bicubic', 'lanczos') | the policy’s own
resize and filter; to cut a frame, seq.tf.crop |
seq.tf.action_project cannot convert ee_absolute -> joint_delta: a space change re-derives the
action's numbers, which the platform does not do. Write the conversion with seq.tf.custom('to_sim', fn),
where fn takes the ActionChunk and returns the sim action as list[list[float]] — one list of values per
step. | write the conversion yourself (Write your own transform) |
seq.tf.action_project select is the dimensions to keep (0-based, in order), got [] |
the dimensions to keep, from 0; leave select out to keep every one |
@seq.adapter(name='toy adapter'): 1-64 letters, digits, '.', '_' or '-', starting with a
letter | a name such as toy-adapter |